Idempotency في Laravel: كيف تمنع تكرار الطلبات والدفع مرتين

الـ Idempotency تعني أن تكرار الطلب نفسه لا يغيّر النتيجة عن تنفيذه مرة واحدة. تعلّم كيف تطبّقها في Laravel لمنع تكرار الطلبات والمدفوعات والـ jobs.

المستخدم يضغط «ادفع»، فينقطع الاتصال قبل وصول الرد. هل نجحت العملية؟ لا يعرف. فيعيد الضغط، أو يعيد تطبيق الجوال المحاولة تلقائيًا، وفجأة يوجد طلبان وخصمان من البطاقة. هذه المشكلة ليست خطأً نادرًا: في أي نظام موزّع يمكن أن يضيع الرد حتى لو نجح التنفيذ، ولذلك تُعاد الطلبات. الحل ليس منع إعادة المحاولة، بل جعل الإعادة آمنة، وهذا ما تعنيه الـ Idempotency.

ما معنى Idempotency؟

بحسب تعريف RFC 9110، تُعدّ العملية idempotent إذا كان الأثر المقصود على الخادم من عدة طلبات متطابقة هو نفسه أثر طلب واحد. وبحسب توثيق MDN، فالطرق التالية idempotent: GET وHEAD وOPTIONS وTRACE وPUT وDELETE، بينما POST وPATCH ليستا كذلك.

نقاط تُفهم خطأً كثيرًا:

  • المقصود هو الأثر على الحالة وليس شكل الرد. تكرار DELETE قد يعيد 200 في المرة الأولى و404 في الثانية، ومع ذلك فالطريقة idempotent لأن النتيجة النهائية واحدة: المورد محذوف.
  • الأثر "المقصود" فقط. الخادم قد يسجّل كل طلب في الـ logs، وهذا لا يخرق المفهوم.
  • الـ Idempotency ليست الـ Safety. PUT يغيّر البيانات لكنه idempotent؛ وGET آمن (read-only) وidempotent معًا.

المشكلة العملية أن POST هي الطريقة التي تُنشئ الطلبات وتنفّذ المدفوعات، وهي بالتحديد غير idempotent. لذلك نحتاج أن نضيف الخاصية بأنفسنا.

نمط Idempotency-Key

الأسلوب المعروف، كما تصفه Stripe في توثيقها، أن يولّد العميل مفتاحًا فريدًا ويرسله مع الطلب في ترويسة Idempotency-Key. الخادم يحفظ نتيجة أول طلب بهذا المفتاح (رمز الحالة والجسم)، وأي طلب لاحق بالمفتاح نفسه يعيد النتيجة المحفوظة بدل إعادة التنفيذ. من تفاصيل تطبيق Stripe التي تصلح مرجعًا:

  • المفتاح عشوائي بإنتروبي كافٍ، وتقترح Stripe استخدام UUID v4، وطوله حتى 255 حرفًا، مع تجنّب وضع بيانات حساسة فيه.
  • النتيجة المحفوظة تُعاد حتى لو كانت خطأ 500.
  • يمكن حذف المفاتيح بعد مرور 24 ساعة على الأقل.
  • إن أُعيد استخدام المفتاح بمعاملات مختلفة عن الطلب الأصلي يُرفض الطلب، منعًا لسوء الاستخدام.
  • لا تُحفظ النتيجة إذا فشل التحقق من المدخلات أو تعارض الطلب مع طلب آخر قيد التنفيذ؛ في هذه الحالات يمكن إعادة المحاولة.

هذه تفاصيل خاصة بـ Stripe وليست معيارًا ملزمًا، لكنها تصميم مدروس يمكنك الاستفادة منه.

متى تكون إعادة المحاولة آمنة من جهة العميل؟

كما يوضح MDN، يستطيع العميل إعادة الطلبات idempotent بأمان عند الشك في وصولها. وهذا يعني عمليًا:

  • صمّم نقاط التعديل بحيث تكون PUT وDELETE هي الأصل حيث أمكن: PUT /orders/42/status بالقيمة نفسها مرتين تعطي الحالة النهائية نفسها، بينما POST /orders/42/increment لا تفعل.
  • عند استخدام POST للإنشاء، اطلب من العميل إرسال Idempotency-Key وسجّل ذلك في توثيق الـ API الخاص بك.
  • أعِد رمز الحالة نفسه المحفوظ في المرة الأولى، وأضف ترويسة مثل Idempotent-Replayed ليعرف العميل أن الرد معاد وليس تنفيذًا جديدًا.
  • وفّر رمز خطأ واضحًا (409 أو 422 كما في المثال) للحالتين: طلب قيد التنفيذ، ومفتاح مستخدم مع معاملات مختلفة.

تطبيق Idempotency في Laravel: Middleware مبسّط

التالي Middleware تعليمي يوضح الفكرة. ليس جاهزًا للإنتاج كما هو، لكنه يجسّد العناصر الأساسية: قفل ذري لمنع تنفيذ طلبين بالمفتاح نفسه في وقت واحد، وبصمة (fingerprint) للتحقق أن المعاملات لم تتغير، وتخزين النتيجة 24 ساعة.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class Idempotent
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if (! $key) {
            return $next($request);
        }

        $cacheKey = 'idem:'.$request->user()?->id.':'.$key;
        $fingerprint = hash('sha256', $request->method().$request->path().$request->getContent());

        $lock = Cache::lock($cacheKey.':lock', 30);

        if (! $lock->get()) {
            return response()->json(['message' => 'الطلب قيد التنفيذ.'], 409);
        }

        try {
            if ($saved = Cache::get($cacheKey)) {
                if ($saved['fingerprint'] !== $fingerprint) {
                    return response()->json(['message' => 'المفتاح استُخدم مع طلب مختلف.'], 422);
                }

                return response($saved['body'], $saved['status'], ['Content-Type' => $saved['type']])
                    ->header('Idempotent-Replayed', 'true');
            }

            $response = $next($request);

            Cache::put($cacheKey, [
                'fingerprint' => $fingerprint,
                'status' => $response->getStatusCode(),
                'body' => $response->getContent(),
                'type' => $response->headers->get('Content-Type'),
            ], now()->addHours(24));

            return $response;
        } finally {
            $lock->release();
        }
    }
}

ثم تطبّقه على المسارات الحساسة فقط، مثل مسار إنشاء الطلب أو الدفع.

ملاحظات مهمة على هذا التطبيق

  • القفل يحتاج Cache driver يدعم الأقفال الذرية مثل Redis أو database أو Memcached، وهو شرط توثّقه Laravel لميزات الأقفال في الـ queues.
  • المفتاح مربوط بالمستخدم حتى لا يتصادم مفتاحان من مستخدمَين مختلفين.
  • لا يغني عن قيد في قاعدة البيانات. إن كان لكل طلب شراء معرّف خارجي فاجعله unique في الجدول؛ فهذا خط الدفاع الأخير الذي لا يتأثر بانتهاء صلاحية الكاش.
  • قرّر سياسة الأخطاء. المثال يحفظ أي رد، بما فيه الأخطاء، على طريقة Stripe؛ وقد تفضّل عدم حفظ أخطاء الخادم 5xx لتسمح بإعادة المحاولة.

Idempotency في الـ Queues

الـ jobs في أنظمة الطوابير عمومًا قد تُنفَّذ أكثر من مرة (عند إعادة المحاولة بعد فشل أو انتهاء مهلة)، فصمّمها بحيث لا يضر التكرار: تحقق من الحالة قبل التنفيذ، واستخدم updateOrCreate أو قيودًا فريدة بدل الإضافة العمياء. وإلى جانب ذلك يقدّم Laravel أداتين مرتبطتين بالموضوع، لكنهما مختلفتان في الهدف:

use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return $this->product->id;
    }
}
  • ShouldBeUnique يمنع وجود نسخة ثانية من الـ job نفسه في الطابور؛ تحدد uniqueId() المفتاح، وخاصية UniqueFor مدة القفل بالثواني. وبحسب التوثيق لا تنطبق قيود التفرد على الـ jobs داخل الـ batches.
  • WithoutOverlapping يمنع التنفيذ المتزامن لنفس المفتاح، وعند التعارض يُعاد الـ job إلى الطابور ويستهلك محاولة، فاضبط Tries وMaxExceptions وفق ذلك.

الفرق: الأول يقلّل التكرار قبل أن يبدأ التنفيذ، والثاني ينظّم التنفيذ. كلاهما يعتمد على أقفال ويقلّل المشكلة لكنه لا يضمن وحده أن الأثر الجانبي يحدث مرة واحدة، فتبقى حماية مستوى البيانات (القيود الفريدة والتحقق من الحالة) ضرورية.

أخطاء شائعة

  1. الاعتماد على تعطيل الزر في الواجهة فقط: لا يحمي من إعادة المحاولة التلقائية للشبكة أو العميل.
  2. توليد مفتاح جديد في كل محاولة إعادة؛ يجب أن يبقى المفتاح ثابتًا للعملية المنطقية نفسها.
  3. تجاهل التزامن: التحقق ثم الكتابة دون قفل أو قيد فريد يترك نافذة سباق (race condition).
  4. حفظ المفاتيح للأبد بدل تحديد مدة صلاحية.

الخلاصة

اعتبر أي عملية تنشئ شيئًا أو تحرّك مالًا قابلة للتكرار حتمًا، وصمّمها على هذا الأساس. ابدأ بقيد فريد في قاعدة البيانات، ثم أضف دعم Idempotency-Key للمسارات التي تستقبل POST من عملاء خارجيين، واجعل الـ jobs آمنة عند التكرار. بهذا تتحول إعادة المحاولة من خطر إلى أداة موثوقية.

المصادر