Idempotency يعني أن تنفيذ نفس الـ Request مرتين (أو أكثر) بنفس البيانات يعطي نفس النتيجة بدون أي آثار جانبية بعد المرة الأولى.
السيناريو الكارثي:
المستخدم يضغط على "Place Order" →
شبكة بطيئة →
timeout →
المستخدم يضغط مرة ثانية →
💥 تم إنشاء طلبين! 💥
💳 تم الخصم مرتين! 💳
Safe Operations (آمنة)
GET /products ✅ Safe & Idempotent
GET /orders/{id} ✅ Safe & Idempotent
لا تغير البيانات، يمكن تكرارها ألف مرة بأمان.
Idempotent Operations
PUT /products/{id} ✅ Idempotent
DELETE /orders/{id} ✅ Idempotent
تغير البيانات، لكن التكرار لا يسبب تغييرات إضافية.
Non-Idempotent Operations (المشكلة!)
POST /orders ❌ Non-Idempotent POST /payments ❌ Non-Idempotent POST /send-email ❌ Non-Idempotent
كل تكرار يسبب عملية جديدة → هنا نحتاج Idempotency!
لماذا الموضوع مهم؟
❌ بدون Idempotency:
✅ مع Idempotency:
الحل الأول:
المفهوم الأساسي
Client يرسل Idempotency-Key فريد مع كل request :
POST /api/orders
Headers:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer xxx
Body:
{ "product_id": 123, "quantity": 2 }
Server يفحص:
هل هذا الـ Key موجود من قبل؟
نعم موجود → أرجع نفس الـ Response السابق (لا تنفذ مرة ثانية)
لا، جديد → نفذ العملية واحفظ النتيجة
1. إنشاء جدول Idempotency
bash
php artisan make:migration create_idempotency_keys_table
php// database/migrations/xxxx_create_idempotency_keys_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->string('key', 255)->unique(); // الـ Idempotency Key
$table->string('user_id')->nullable()->index(); // للربط بالمستخدم
$table->string('endpoint', 255); // /api/orders مثلاً
$table->enum('status', ['processing', 'completed', 'failed'])->default('processing');
$table->text('request_payload')->nullable(); // البيانات المرسلة
$table->text('response_data')->nullable(); // النتيجة المحفوظة
$table->integer('response_code')->nullable(); // HTTP Status Code
$table->timestamp('completed_at')->nullable();
$table->timestamps();
// Indexes للأداء
$table->index(['key', 'status']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('idempotency_keys');
}
};bashphp artisan migrate
2. إنشاء Model
php
// app/Models/IdempotencyKey.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class IdempotencyKey extends Model
{
protected $fillable = [
'key',
'user_id',
'endpoint',
'status',
'request_payload',
'response_data',
'response_code',
'completed_at',
];
protected $casts = [
'request_payload' => 'array',
'response_data' => 'array',
'completed_at' => 'datetime',
];
public function isProcessing(): bool
{
return $this->status === 'processing';
}
public function isCompleted(): bool
{
return $this->status === 'completed';
}
public function isFailed(): bool
{
return $this->status === 'failed';
}
}
3. إنشاء Idempotency Middleware
php
// app/Http/Middleware/EnsureIdempotency.php
namespace App\Http\Middleware;
use App\Models\IdempotencyKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class EnsureIdempotency
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// فقط للـ POST requests
if (!$request->isMethod('post')) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key');
// إذا لم يتم إرسال Idempotency-Key، نفذ بشكل عادي
if (!$idempotencyKey) {
return $next($request);
}
// التحقق من وجود الـ Key
$existing = IdempotencyKey::where('key', $idempotencyKey)->first();
if ($existing) {
// إذا كان completed، أرجع الـ Response المحفوظ
if ($existing->isCompleted()) {
return response()->json(
$existing->response_data,
$existing->response_code
)->header('X-Idempotency-Replay', 'true');
}
// إذا كان processing، أرجع 409 Conflict
if ($existing->isProcessing()) {
return response()->json([
'message' => 'Request is already being processed',
'idempotency_key' => $idempotencyKey,
], 409);
}
// إذا كان failed، يمكن إعادة المحاولة
if ($existing->isFailed()) {
$existing->update([
'status' => 'processing',
'completed_at' => null,
]);
}
} else {
// إنشاء سجل جديد
try {
IdempotencyKey::create([
'key' => $idempotencyKey,
'user_id' => $request->user()?->id,
'endpoint' => $request->path(),
'status' => 'processing',
'request_payload' => $request->except(['password', 'password_confirmation']),
]);
} catch (\Exception $e) {
// Race condition: key تم إنشاؤه للتو
return response()->json([
'message' => 'Request is already being processed',
'idempotency_key' => $idempotencyKey,
], 409);
}
}
// تخزين الـ key في الـ request للاستخدام لاحقاً
$request->attributes->set('idempotency_key', $idempotencyKey);
// تنفيذ الـ Request
$response = $next($request);
// حفظ النتيجة
$this->saveResponse($idempotencyKey, $response);
return $response;
}
/**
* حفظ Response للاستخدام المستقبلي
*/
protected function saveResponse(string $key, Response $response): void
{
try {
$record = IdempotencyKey::where('key', $key)->first();
if ($record) {
$record->update([
'status' => $response->isSuccessful() ? 'completed' : 'failed',
'response_data' => json_decode($response->getContent(), true),
'response_code' => $response->getStatusCode(),
'completed_at' => now(),
]);
}
} catch (\Exception $e) {
// Log error but don't fail the request
\Log::error('Failed to save idempotency response: ' . $e->getMessage());
}
}
}
4. تسجيل الـ Middleware
php
// في bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'idempotent' => \App\Http\Middleware\EnsureIdempotency::class,
]);
})
// أو في app/Http/Kernel.php (Laravel 10 وأقدم)
protected $middlewareAliases = [
'idempotent' => \App\Http\Middleware\EnsureIdempotency::class,
];
5. استخدام Middleware في Routes
php
// routes/api.php
use App\Http\Controllers\OrderController;
use App\Http\Controllers\PaymentController;
Route::middleware(['auth:sanctum', 'idempotent'])->group(function () {
// كل الـ POST requests هنا محمية بـ Idempotency
Route::post('/orders', [OrderController::class, 'store']);
Route::post('/payments', [PaymentController::class, 'process']);
Route::post('/send-notification', [NotificationController::class, 'send']);
});
6. مثال في Controller
php
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'quantity' => 'required|integer|min:1',
'payment_method' => 'required|string',
]);
try {
DB::beginTransaction();
// إنشاء الطلب
$order = Order::create([
'user_id' => $request->user()->id,
'product_id' => $validated['product_id'],
'quantity' => $validated['quantity'],
'total' => $this->calculateTotal($validated),
'status' => 'pending',
]);
// معالجة الدفع
$payment = $this->processPayment($order, $validated['payment_method']);
// تحديث المخزون
$this->updateInventory($validated['product_id'], $validated['quantity']);
DB::commit();
return response()->json([
'message' => 'Order created successfully',
'order' => $order,
'payment' => $payment,
], 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'message' => 'Failed to create order',
'error' => $e->getMessage(),
], 500);
}
}
}
استخدام من جانب الـ Client
JavaScript / Vue / React
javascript//
توليد Idempotency Key فريد
function generateIdempotencyKey() {
return crypto.randomUUID(); // أو أي UUID generator
}
// حفظ الـ key مع كل request
const placeOrder = async (orderData) => {
const idempotencyKey = generateIdempotencyKey();
// حفظ الـ key في localStorage لتجنب إرسال نفس الـ request
localStorage.setItem('last_order_key', idempotencyKey);
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Idempotency-Key': idempotencyKey, // هنا الـ magic!
},
body: JSON.stringify(orderData),
});
const data = await response.json();
if (response.status === 409) {
console.log('Request already processing...');
// يمكن عمل retry بعد ثواني
return;
}
if (response.ok) {
// تحقق من header للتأكد إذا كان replay
const isReplay = response.headers.get('X-Idempotency-Replay');
if (isReplay) {
console.log('This is a replayed response');
}
return data;
}
} catch (error) {
console.error('Order failed:', error);
}
};
Flutter / Dart
dartimport 'package:uuid/uuid.dart';
import 'package:http/http.dart' as http;
class ApiService {
final uuid = Uuid();
Future<Map<String, dynamic>> placeOrder(Map<String, dynamic> orderData) async {
final idempotencyKey = uuid.v4();
try {
final response = await http.post(
Uri.parse('https://api.example.com/orders'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
'Idempotency-Key': idempotencyKey,
},
body: jsonEncode(orderData),
);
if (response.statusCode == 409) {
// Request already processing
print('Request is being processed');
return {'status': 'processing'};
}
if (response.statusCode >= 200 && response.statusCode < 300) {
final isReplay = response.headers['x-idempotency-replay'];
if (isReplay == 'true') {
print('This is a cached response');
}
return jsonDecode(response.body);
}
} catch (e) {
print('Error: $e');
rethrow;
}
}
}
المفهوم
استخدام معرف فريد من External Service (مثل Payment Gateway) كـ Unique Constraint في Database.
مثال عملي: Payment Integration
php
// Migration
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->string('payment_intent_id')->unique(); // 🔑 المفتاح السحري
$table->string('payment_provider'); // stripe, paypal, etc
$table->decimal('amount', 10, 2);
$table->string('currency', 3);
$table->enum('status', ['pending', 'completed', 'failed']);
$table->timestamps();
});php
// في Controller أو Service
namespace App\Services;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use Stripe\PaymentIntent;
class PaymentService
{
public function processStripePayment(Order $order, string $paymentIntentId)
{
try {
DB::beginTransaction();
// محاولة إنشاء Payment record
$payment = Payment::create([
'order_id' => $order->id,
'payment_intent_id' => $paymentIntentId, // Unique!
'payment_provider' => 'stripe',
'amount' => $order->total,
'currency' => 'USD',
'status' => 'pending',
]);
// التأكيد من Stripe
$intent = PaymentIntent::retrieve($paymentIntentId);
if ($intent->status === 'succeeded') {
$payment->update(['status' => 'completed']);
$order->update(['status' => 'paid']);
}
DB::commit();
return ['success' => true, 'payment' => $payment];
} catch (\Illuminate\Database\QueryException $e) {
DB::rollBack();
// تحقق إذا كان Duplicate Entry Error
if ($e->getCode() === '23000') { // Unique constraint violation
// الـ payment موجود مسبقاً
$existing = Payment::where('payment_intent_id', $paymentIntentId)->first();
return [
'success' => true,
'payment' => $existing,
'message' => 'Payment already processed',
'duplicate' => true,
];
}
throw $e;
}
}
}
مثال: Transaction ID من External API
php
// app/Models/Transaction.php
class Transaction extends Model
{
protected $fillable = [
'external_transaction_id', // من Payment Gateway
'user_id',
'amount',
'status',
];
// Event للتعامل مع Duplicate
protected static function boot()
{
parent::boot();
static::creating(function ($transaction) {
// التحقق قبل الإنشاء
$exists = static::where('external_transaction_id', $transaction->external_transaction_id)
->exists();
if ($exists) {
throw new \DomainException('Transaction already exists');
}
});
}
}
php
// في Controller
public function processTransaction(Request $request)
{
try {
$transaction = Transaction::create([
'external_transaction_id' => $request->transaction_id,
'user_id' => auth()->id(),
'amount' => $request->amount,
'status' => 'completed',
]);
return response()->json([
'message' => 'Transaction processed',
'transaction' => $transaction,
], 201);
} catch (\DomainException $e) {
// Transaction موجود مسبقاً
$existing = Transaction::where('external_transaction_id', $request->transaction_id)
->first();
return response()->json([
'message' => 'Transaction already processed',
'transaction' => $existing,
'duplicate' => true,
], 200); // 200 لأن العملية تمت بالفعل
}
}
استخدم Idempotency-Key عندما:
✅ تحتاج تحكم كامل في العملية
✅ تريد معرفة حالة الـ Request (processing/completed/failed)
✅ لا يوجد معرف فريد خارجي
✅ تحتاج retry logic معقد
استخدم Unique Constraint عندما:
✅ يوجد معرف فريد من External Service
✅ تريد حل بسيط وسريع
✅ لا تحتاج تتبع حالات معقدة
✅ الأداء هو الأولوية القصوى
1. Cleanup للـ Keys القديمة
php
// app/Console/Commands/CleanupIdempotencyKeys.php
namespace App\Console\Commands;
use App\Models\IdempotencyKey;
use Illuminate\Console\Command;
class CleanupIdempotencyKeys extends Command
{
protected $signature = 'idempotency:cleanup {--days=7}';
protected $description = 'Clean up old idempotency keys';
public function handle()
{
$days = $this->option('days');
$deleted = IdempotencyKey::where('created_at', '<', now()->subDays($days))
->where('status', 'completed')
->delete();
$this->info("Deleted {$deleted} old idempotency keys");
}
}php
// في app/Console/Kernel.php أو routes/console.php
Schedule::command('idempotency:cleanup')->daily();
2. Monitoring
php
// في AppServiceProvider
use App\Models\IdempotencyKey;
public function boot()
{
// تنبيه عند وجود requests عالقة
if (app()->environment('production')) {
$stuck = IdempotencyKey::where('status', 'processing')
->where('created_at', '<', now()->subMinutes(5))
->count();
if ($stuck > 10) {
\Log::warning("Found {$stuck} stuck idempotency keys");
// أرسل تنبيه
}
}
}
✅ يمنع Duplicate Operations تحت أي ظرف
✅ يحمي من الأخطاء البشرية (الضغط المزدوج)
✅ يتعامل مع Network Issues بأمان
✅ يدعم Automatic Retries بدون قلق
✅ يحسن تجربة المستخدم (لا توجد مفاجآت)