مفتاح idempotency: منع تكرار الطلب في واجهات الدفع

بقلم فريق تقني · (آخر تحديث: )· برمجة
مفتاح idempotency: منع تكرار الطلب في واجهات الدفع

خادمك لا يملك ما يفرّق به بين عميل يطلب عملية ثانية وعميل يعيد إرسال الأولى بعد أن ابتلعت الشبكة الرد. الطلبان يصلان متطابقين، فيُنفَّذان معاً ويُخصم المبلغ مرتين، والخصم المزدوج يجرّ على التاجر نزاع استرداد فوق غضب العميل. الانقطاع نفسه أمر متوقع لا حيلة فيه؛ المشكلة أن الطلب لا يحمل ما يميّزه.

المفتاح هو ذلك المميِّز: قيمة يختارها العميل قبل أول إرسال وتلازم العملية الواحدة في كل محاولاتها. يسجّلها الخادم مع نتيجة أول تنفيذ، ثم يردّ النتيجة نفسها على أي طلب لاحق يحملها بدل تنفيذها من جديد. الآلية بهذه البساطة نظرياً؛ ما يوقِع المطورين فعلاً ترتيبٌ خاطئ لخطوتين في قاعدة البيانات، أو كود منسوخ من مقالات Stripe لا يعمل كما هو مع بوابات المنطقة.

لماذا يحتاج POST إلى هذا

يصنّف RFC 9110 الطرق الآمنة وPUT وDELETE على أنها idempotent: الأثر المقصود على الخادم من تكرار الطلب نفسه هو أثر طلب واحد. أما POST — ومثله PATCH — فخارج القائمة، فكل تكرار قد يُحدث أثراً جديداً وفق منطق المورد الخاص. لكن هذا وصف للطريقة لا حكم على مسارك؛ يصير POST آمناً للتكرار متى امتلكت وسيلة تكشف أن الطلب تكرار لسابق — وهذه وظيفة المفتاح.

ترويسة Idempotency-Key ليست معياراً. هي مسودة في مجموعة httpapi التابعة لـIETF، أحدث نسخة منها -07 في أكتوبر 2025، وانتهت صلاحيتها في أبريل 2026 دون أن تصدر بصيغة RFC. تبقى مع ذلك أفضل مرجع مشترك: تحظر إعادة استخدام المفتاح مع حمولة مختلفة، وترمّز الأخطاء: 400 لغياب مفتاح إلزامي، و422 لحمولة مختلفة بالمفتاح نفسه، و409 لمحاولة سبقت اكتمال الأصل. غياب معيار صادر هو سبب ما ستراه في جدول المزوّدين أدناه: لا موضع ولا مدة ولا سلوك موحّد عند التعارض.

التنفيذ في الخادم: أدخل أولاً ثم نفّذ

جدول واحد يكفي، وفيه فهرس فريد على (معرّف الحساب، المفتاح). خزّن مع كل مفتاح ثلاثة حقول: بصمة جسم الطلب، ورمز حالة الرد، وجسم الرد.

CREATE TABLE idempotency_keys (
    id bigserial PRIMARY KEY,
    account_id bigint NOT NULL,
    idempotency_key text NOT NULL,
    request_fingerprint text NOT NULL,
    response_status int,
    response_body jsonb,
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT idempotency_keys_account_key_uniq UNIQUE (account_id, idempotency_key)
);

INSERT INTO idempotency_keys (account_id, idempotency_key, request_fingerprint)
VALUES ($1, $2, $3)
ON CONFLICT (account_id, idempotency_key) DO NOTHING
RETURNING id;

الترتيب هو الجوهر: أدخل السجل أولاً ثم نفّذ. التعارض على الفهرس الفريد هو إشارة «هذا تكرار».

الخطأ الشائع أن تفحص وجود المفتاح في كود التطبيق ثم تُدخِل السجل. في مستوى العزل الافتراضي Read Committed، لا يرى استعلامك إدخال معاملة متزامنة لم تُثبَّت بعد، فيمرّ الطلبان معاً ويُنفَّذ الخصم مرتين. الفهرس الفريد في قاعدة البيانات هو نقطة التسلسل الموثوقة الوحيدة.

إن كان الأثر داخل قاعدة بياناتك وحدها، فاجعل تسجيل المفتاح وتنفيذه في معاملة واحدة. أما نداء بوابة الدفع فلا يُضمّ إلى معاملة: لا يتراجع مع تراجعها، وإبقاؤها مفتوحة حول نداء شبكي يطيل القفل بلا فائدة. الترتيب هناك ثلاث خطوات: ثبّت صف المفتاح، ثم نادِ البوابة، ثم اكتب رمز الرد وجسمه في عبارة ثانية. لا تسجّل المفتاح بعد الخصم؛ إن سقط الخادم بينهما عاد العميل فوجد الطريق مفتوحاً وتكرّر الخصم.

وثمة سلوك في ON CONFLICT DO NOTHING ... RETURNING يخدع كثيرين: لا صف يُرجَع عند التعارض، فهو لا يعيد إلا الصفوف التي أُدخلت فعلاً. غياب النتيجة هو حالة التكرار نفسها، فاقرأ السجل القائم. إن كان response_status فيه ما زال NULL فالطلب الأول لم ينتهِ بعد، فأرجِع 409 واطلب إعادة المحاولة بالمفتاح نفسه. وإن امتلأ الحقل فأعد الرد المحفوظ كما هو.

احذف الصفوف القديمة دورياً بحسب created_at بمدة احتفاظ أطول من أطول نافذة إعادة محاولة عندك. هذا كله يعالج التكرار الصادر منك؛ الوجه المقابل تكرارٌ واردٌ إليك حين يسلّم المزوّد الحدث نفسه أكثر من مرة، وعلاجه في منع تكرار الأحداث داخل معالج الويب هوك.

نقاط الاستئناف حين يتعدد الأثر الخارجي

الترتيب الثلاثي أعلاه يفترض نداءً خارجياً واحداً. في مقالته «Implementing Stripe-like Idempotency Keys in Postgres» يعالج براندور ليتش الحالة الأعم بمثال طلب رحلة يُنشئ سجلها محلياً ثم يخصم البطاقة عبر Stripe: إن سقط الخادم بين الخطوتين فلا ردّ مخزّناً يُعاد، وإعادة التنفيذ من الصفر تنشئ رحلة ثانية.

حلّه تقسيم الطلب إلى أطوار ذرّية: كل تعديل لحالة خارجية يأخذ طوره الخاص، وما بينها تغييرات محلية تحميها معاملة واحدة بضمانات ACID. بعد كل طور يكتب الخادم نقطة استئناف مسماة في صف المفتاح، من started إلى finished، فتتجاوز إعادةُ المحاولة الأطوار المكتملة: الرحلة موجودة؟ إذن لا يُعاد إلا الخصم. يضيف مخططه حقلي locked_at لمنع معالجة متزامنة للمفتاح نفسه وrequest_params لكشف اختلاف الحمولة.

والبنية نفسها طبّقتها Airbnb في إطار Orpheus عام 2019، بقفل صف المفتاح قبل النداء الشبكي وتصنيف الأخطاء إلى قابلة لإعادة المحاولة وغير قابلة.

سجل المفاتيح في Postgres لا في Redis

يغري Redis بقفل ذري سريع، لكنه لا يصلح مصدر حقيقة هنا. نسخه غير متزامن افتراضياً، وأمر WAIT لا يمنحه اتساقاً قوياً كما تقرّ وثائقه: كتابات أُكّدت للعميل قد تضيع عند تبديل الخادم الرئيس. والتخزين الدائم لا يسدّ الثغرة: لقطات RDB تفقد آخر دقائق، وAOF بإعداد everysec يفقد حتى ثانية، وإعداد always بطيء عملياً.

وتشترط مكتبة بناة AWS أن يخضع تسجيل المفتاح والعمليات المعدِّلة معاً لضمانات ACID، وهو مستحيل بين Redis منفصل وقاعدة عملك؛ فضياع صف المفتاح بعد خصم ناجح يساوي السماح بخصم ثانٍ. فليعش الصف في قاعدة بيانات العمل ومعاملتها، وليبقَ Redis قفلاً أمامياً يخفف الحمل لا غير.

بصمة جسم الطلب: التخزين ثم المقارنة

المفتاح وحده لا يضمن تطابق الطلبين. لو أعاد العميل استخدام مفتاح عملية بمبلغ 100 في طلب جديد بمبلغ 500 فأعدت الرد المحفوظ، تكون قد أكّدت له عملية لم تحدث. عند اختلاف البصمة أرجِع خطأً صريحاً لا الرد المحفوظ — 422 بحسب المسودة.

وطريقة الحساب مصيدة بذاتها: تجزئة بايتات الطلب الخام تنكسر إذا أعادت مكتبة العميل ترتيب حقول JSON أو غيّرت المسافات بين محاولتين والطلب لم يتغير فعلياً. الخياران السليمان: توحيد الصياغة وفق RFC 8785 — ترتيب الحقول وتوحيد تمثيل الأرقام — ثم التجزئة، أو تخزين المعاملات المفسَّرة ومقارنتها بنيوياً، وهو نهج Stripe التي ترد عند الاختلاف بخطأ من نوع idempotency_error، ونهج ليتش بحقل request_params.

لماذا لا يُنسَخ كود Stripe إلى Tap وMoyasar

أكثر ما يُكتب عن المفاتيح مفصّل على مقاس Stripe وحده. انقل ذلك الكود إلى بوابة إقليمية فلن يعمل، لأن المفتاح فيها ليس ترويسةً أصلاً، بل حقلاً داخل جسم الطلب.

المزوّدأين يوضع المفتاحالمدةسلوك التعارض
Stripe v1ترويسة Idempotency-Key، حتى 255 حرفاً24 ساعةيعيد الرد المحفوظ حتى لو كان خطأ؛ اختلاف المعاملات idempotency_error؛ طلبان متزامنان 409 مع idempotency_key_in_use
Stripe v2الترويسة نفسها، وتولّدها Stripe تلقائياً إن لم ترسلها30 يوماً داخل الحساب نفسهتعيد تنفيذ الطلبات الفاشلة بدل إرجاع الخطأ المحفوظ، وتشمل POST وDELETE
Adyenترويسة idempotency-key، 64 حرفاً7 أيام على الأقل422 أو 409 برمز 704
Squareالحقل idempotency_key داخل الجسم، 45 حرفاًغير موثّقةإعادة ببيانات مختلفة: 400 برمز IDEMPOTENCY_KEY_REUSED
Tapالقيمة داخل الجسم في reference.idempotent24 ساعةيعيد الرد الأصلي دون تنفيذ عملية جديدة
Moyasargiven_id بصيغة UUID داخل الجسمغير موثّقةإعادة استخدامه لدفعة مختلفة: 400

لا توثّق Moyasar ترويسة للمفاتيح؛ فالعميل يولّد given_id ليصبح هو معرّف عملية الدفع. واختلاف البوابات لا يقف عند موضع المفتاح: صيغة حقل المبلغ نفسها تتبدل بينها، وذلك موضوع تخزين المبالغ المالية في قاعدة البيانات.

مدد الاحتفاظ نافذة تنتهي بصمت

Stripe تحذف المفاتيح بعد 24 ساعة على الأقل، وتعيد حتى الردود الفاشلة المخزنة — بما فيها ردود 500 — واضعةً على الرد المعاد ترويسة Idempotent-Replayed: true. PayPal تختلف وثائقها مع نفسها حول ترويسة PayPal-Request-Id: 6 ساعات افتراضياً لواجهة الطلبات، مقابل «حتى 45 يوماً» في صفحة REST العامة. Checkout.com تخزّن مفتاح Cko-Idempotency-Key عند ردود النجاح 2xx فقط لمدة افتراضية 24 ساعة، وAmazon Pay العالمية تحتفظ بمفاتيح x-amz-pay-idempotency-key بلا أجل.

القاعدة: جدول إعادة محاولات أطول من مدة احتفاظ المزوّد يجرّد المفتاح من قيمته دون إنذار، فالمحذوف يعامَل طلباً جديداً.

بوابات الخليج: طبقة تبنيها بنفسك

HyperPay القائمة على منصة OPPWA، وPayTabs، وAmazon Payment Services — وهي PayFort سابقاً، لا Amazon Pay العالمية — لا توثّق أي آلية مفاتيح، وتكتفي باشتراط مرجع تاجر فريد: merchantTransactionId أو cart_id أو merchant_reference. من يبني عليها يقيم الطبقة كاملة في خادمه بالجدول والترتيب أعلاه، والمرجع الفريد المشروط هو بعينه حالة المعرّف الطبيعي في القسم الأخير.

أخطاء العميل التي تُبطل الفائدة

أشهرها توليد مفتاح جديد مع كل محاولة إعادة، وهذا يلغي الآلية من أساسها. المفتاح يُولَّد مرة واحدة عند تكوين نية العملية، ويُخزَّن محلياً، ويُعاد كما هو في كل محاولة. صيغة UUID كافية لتوليده — وتفضّل Shopify عليها صيغة ULID بعدما قاست إدخالات أسرع بنحو 50% في نظام عالي الإنتاجية — وإن كان اختيار نوع المفتاح داخل قاعدة بياناتك قراراً منفصلاً شرحناه في UUID أم bigint.

ثانيها أن تُعامل 409 على أنه فشل نهائي. الرمز يعني أن طلباً بالمفتاح نفسه ما زال قيد المعالجة، فأعد المحاولة بالمفتاح نفسه بعد تراجع أسّي.

ثالثها استخدام مفتاح واحد لعدة عمليات في المسار. التفويض والتحصيل عمليتان، ولكل واحدة مفتاحها.

انتبه إلى نطاق التفرّد: عند Adyen على مستوى حساب الشركة، وعند Stripe داخل الحساب أو بيئة الاختبار. المفتاح نفسه في نطاق آخر يُنشئ عملية جديدة لا يمنعها شيء.

متى تحتاج إلى جدول مفاتيح أصلاً؟

إن كان للعملية معرّف طبيعي فريد يعرفه العميل مسبقاً — رقم الطلب، أو معرّف الحجز، أو مرجع التاجر الذي تشترطه بوابات الخليج — فقيدٌ فريد على ذلك العمود يحلّ المشكلة بلا بنية إضافية.

الجدول الكامل يلزم في ثلاث حالات: أن تستدعي نظاماً خارجياً لا تراجع فيه، أو أن يولّد الخادم المعرّف فلا يملك العميل ما يميّز به طلبه، أو أن يُشترط عليك إرجاع الرد نفسه حرفياً في كل إعادة.