Idempotency: منع تكرار العمليات الحرجة

2025-11-27 وقت القراءه : 10 دقائق

ما هو 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 بدون قلق

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

إضافة تعليق
Loading...