ما هو Idempotency ولماذا هو مهم جداً؟

Idempotency يعني أن تنفيذ نفس الـ Request مرتين (أو أكثر) بنفس البيانات يعطي نفس النتيجة بدون أي آثار جانبية بعد المرة الأولى.

السيناريو الكارثي:

المستخدم يضغط على "Place Order" → 

شبكة بطيئة → 

timeout → 

المستخدم يضغط مرة ثانية → 

? تم إنشاء طلبين! ?

? تم الخصم مرتين! ?

الفرق بين Safe و Idempotent

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!

لماذا الموضوع مهم؟

  • الشبكات غير مستقرة: Timeout, Retry, Network errors
  • المستخدمون يخطئون: الضغط المزدوج على الأزرار
  • Queue Retries: Jobs قد تُنفذ أكثر من مرة
  • API Clients: Automatic retries في حالة الفشل

❌ بدون Idempotency:

  • Duplicate payments ?
  • Duplicate orders ??
  • Duplicate emails ???
  • فوضى في قاعدة البيانات

✅ مع Idempotency:

  • نفس الـ Request → نفس النتيجة
  • حماية من التكرار
  • تجربة مستخدم ممتازة

الحل الأول:

 Idempotency Key في الـ Header

المفهوم الأساسي

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 السابق (لا تنفذ مرة ثانية)

لا، جديد → نفذ العملية واحفظ النتيجة

التطبيق في Laravel

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;
    }
  }
}

الحل الثاني: Unique Constraint على Business ID

المفهوم

استخدام معرف فريد من 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

✅ تريد حل بسيط وسريع

✅ لا تحتاج تتبع حالات معقدة

✅ الأداء هو الأولوية القصوى

Best Practices

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");
            // أرسل تنبيه
        }
    }
}

الخلاصة، لماذا Idempotency ضروري؟

✅ يمنع Duplicate Operations تحت أي ظرف

✅ يحمي من الأخطاء البشرية (الضغط المزدوج)

✅ يتعامل مع Network Issues بأمان

✅ يدعم Automatic Retries بدون قلق

✅ يحسن تجربة المستخدم (لا توجد مفاجآت)