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

خادمك لا يملك ما يفرّق به بين عميل يطلب عملية ثانية وعميل يعيد إرسال الأولى بعد أن ابتلعت الشبكة الرد. الطلبان يصلان متطابقين في كل شيء، فيُنفَّذان معاً ويُخصم المبلغ مرتين. الانقطاع نفسه أمر متوقع لا حيلة فيه؛ المشكلة أن الطلب لا يحمل ما يميّزه.
المفتاح هو ذلك المميِّز: قيمة فريدة يولّدها العميل مرة واحدة ويعيدها مع كل محاولة. يسجّلها الخادم مع نتيجة أول تنفيذ، ثم يردّ النتيجة نفسها على أي طلب لاحق يحملها بدل تنفيذ العملية من جديد. الفكرة تُشرح في سطرين، والسقوط يقع في التنفيذ: ترتيب خاطئ لخطوتين في قاعدة البيانات، أو كود منسوخ من مقالات Stripe لا يعمل كما هو مع بوابات المنطقة.
لماذا يحتاج POST إلى هذا
يصنّف RFC 9110 الطرق الآمنة وPUT وDELETE على أنها idempotent: الأثر المقصود على الخادم من تكرار الطلب نفسه هو أثر طلب واحد. أما POST — ومثله PATCH — فخارج القائمة، لأن دلالته أن المورد يعالج ما أُرسل إليه وفق منطقه الخاص، فقد يُحدث كل تكرار أثراً جديداً.
لكن هذا وصف للطريقة لا حكم على مسارك؛ يصير مسار POST بعينه آمناً للتكرار متى امتلكت وسيلة تعرف بها أن هذا الطلب تكرار لطلب سابق. المفتاح هو تلك الوسيلة.
ترويسة Idempotency-Key ليست معياراً. هي مسودة في مجموعة httpapi التابعة لـIETF، أحدث نسخة منها -07 في أكتوبر 2025، وانتهت صلاحيتها في أبريل 2026 دون أن تصدر بصيغة RFC. غياب المعيار هو سبب ما ستراه في جدول المزوّدين أدناه: لا موضع موحّد ولا مدة موحّدة ولا سلوك موحّد عند التعارض.
التنفيذ في الخادم: أدخل أولاً ثم نفّذ
جدول واحد يكفي، وفيه فهرس فريد على (معرّف الحساب، المفتاح). خزّن مع كل مفتاح ثلاثة حقول: بصمة جسم الطلب، ورمز حالة الرد، وجسم الرد.
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 لا يُرجع صفاً عند التعارض، لأن RETURNING لا يعيد إلا الصفوف التي أُدخلت فعلاً. غياب النتيجة هو حالة التكرار نفسها، فاقرأ السجل القائم. إن كان response_status فيه ما زال NULL فالطلب الأول لم ينتهِ بعد، فأرجِع 409 واطلب إعادة المحاولة بالمفتاح نفسه بدل أن تعيد رداً فارغاً. وإن امتلأ الحقل فأعد الرد المحفوظ كما هو.
احذف الصفوف القديمة دورياً بحسب created_at، ولتكن مدة الاحتفاظ أطول من أطول نافذة إعادة محاولة عندك ومن مدة المزوّد في الجدول أدناه.
هذا يعالج التكرار الصادر منك. الوجه المقابل تكرارٌ واردٌ إليك، إذ يسلّم المزوّد الحدث نفسه أكثر من مرة، وعلاجه في منع تكرار الأحداث داخل معالج الويب هوك.
لماذا تُخزَّن بصمة جسم الطلب
المفتاح وحده لا يضمن تطابق الطلبين. لو أعاد العميل استخدام مفتاح عملية بمبلغ 100 في طلب جديد بمبلغ 500 فأعدت الرد المحفوظ، تكون قد أكّدت له عملية لم تحدث. عند اختلاف البصمة، أرجِع خطأً صريحاً لا الرد المحفوظ.
لماذا لا يُنسَخ كود 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.idempotent | 24 ساعة | يعيد الرد الأصلي دون تنفيذ عملية جديدة |
| Moyasar | given_id بصيغة UUID داخل الجسم | غير موثّقة | إعادة استخدامه لدفعة مختلفة: 400 |
لا توثّق Moyasar ترويسة للمفاتيح؛ فالعميل يولّد given_id ليصبح هو معرّف عملية الدفع. واختلاف البوابات لا يقف عند موضع المفتاح: صيغة حقل المبلغ نفسها تتبدل بينها، وذلك موضوع تخزين المبالغ المالية في قاعدة البيانات.
أخطاء العميل التي تُبطل الفائدة
أشهرها توليد مفتاح جديد مع كل محاولة إعادة، وهذا يلغي الآلية من أساسها. المفتاح يُولَّد مرة واحدة عند تكوين نية العملية، ويُخزَّن محلياً، ويُعاد كما هو في كل محاولة. صيغة UUID كافية لتوليده في العميل، وإن كان اختيار نوع المفتاح داخل قاعدة بياناتك قراراً منفصلاً شرحناه في UUID أم bigint.
ثانيها أن تُعامل 409 على أنه فشل نهائي. الرمز يعني أن طلباً بالمفتاح نفسه ما زال قيد المعالجة، فأعد المحاولة بالمفتاح نفسه بعد تراجع أسّي.
ثالثها استخدام مفتاح واحد لعدة عمليات في المسار. التفويض والتحصيل عمليتان، ولكل واحدة مفتاحها.
انتبه إلى نطاق التفرّد: عند Adyen على مستوى حساب الشركة، وعند Stripe داخل الحساب أو بيئة الاختبار. المفتاح نفسه في نطاق آخر يُنشئ عملية جديدة لا يمنعها شيء.
متى تحتاج إلى جدول مفاتيح أصلاً؟
إن كان للعملية معرّف طبيعي فريد يعرفه العميل مسبقاً — رقم الطلب أو معرّف الحجز — فقيدٌ فريد على ذلك العمود يحلّ المشكلة بلا بنية إضافية.
الجدول الكامل يلزم في ثلاث حالات: أن تستدعي نظاماً خارجياً لا تراجع فيه، أو أن يولّد الخادم المعرّف فلا يملك العميل ما يميّز به طلبه، أو أن يُشترط عليك إرجاع الرد نفسه حرفياً في كل إعادة.