Idempotency Keys: امنع الدفع يتكرر لما الـ retry يحصل
مستوى القارئ: متوسط
لو عندك API بتعمل إنشاء طلب أو دفع، المقال ده هيخليك تضيف retry آمن بدون ما تعمل طلبين أو تخصم من العميل مرتين.
المشكلة باختصار
الطريقة الشائعة الغلط إنك تقول: "لو الطلب فشل، ابعته تاني". الطريقة دي بتفشل لما الفشل يكون timeout من الشبكة، مش فشل حقيقي في السيرفر. اللي بيحصل فعلاً إن السيرفر ممكن يكون نفّذ العملية، لكن الرد ماوصلش للعميل.
مثال واقعي: مستخدم ضغط "ادفع" في checkout. الـ payment gateway خصمت 250 جنيه، لكن اتصال الموبايل قطع قبل ما تطبيقك يستلم الرد. التطبيق يعمل retry تلقائي. بدون حماية، ممكن تعمل charge تاني. مع Idempotency Key، نفس الطلب يرجع نفس النتيجة بدل ما يتنفذ من جديد.
المفهوم بالمثال قبل التعريف
ركز في المثال ده. تخيل إن عندك تذكرة انتظار برقم 742. لو الموظف سمعك غلط وطلبت منه نفس الخدمة تاني بنفس رقم التذكرة، المفروض يرجع لنفس المعاملة، مش يفتح معاملة جديدة. Idempotency Key هو رقم التذكرة ده، لكن للـ API.
التعريف الدقيق: مفتاح idempotency هو قيمة فريدة يرسلها العميل مع طلب تغيير حالة مثل إنشاء order أو تنفيذ payment. السيرفر يخزن المفتاح مع بصمة جسم الطلب والرد النهائي. لو نفس المفتاح رجع مرة أخرى، السيرفر لا ينفذ العملية من جديد، بل يرجع الرد المخزن أو يرفض الطلب لو الـ payload مختلف.
Stripe، مثلًا، توصي باستخدام مفاتيح فريدة مثل UUID v4، وتتعامل مع إعادة نفس المفتاح بإرجاع نفس نتيجة الطلب الأول. وMDN توضح أن crypto.randomUUID() يولد UUID v4 عشوائيًا بطول 36 حرفًا باستخدام مولد آمن تشفيريًا في السياقات الآمنة.
تنفيذ عملي بـ Node.js وPostgreSQL
الافتراض إن عندك endpoint لإنشاء order، وقاعدة PostgreSQL، ومتوسط retries عندك 1 إلى 3 مرات عند timeout. أفضل طريقة هنا إن السيرفر يعتمد على جدول مستقل للمفاتيح، ومعاه unique constraint يمنع السباق بين طلبين وصلوا في نفس اللحظة.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL,
status_code integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
وفي Express، خزن المفتاح قبل تنفيذ العملية. استخدم hash للـ body عشان تمنع إعادة استخدام نفس المفتاح مع بيانات مختلفة.
import crypto from "node:crypto";
function hashBody(body) {
return crypto.createHash("sha256")
.update(JSON.stringify(body))
.digest("hex");
}
app.post("/orders", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
const requestHash = hashBody(req.body);
const existing = await db.query(
"SELECT request_hash, status_code, response_body FROM idempotency_keys WHERE key = $1",
[key]
);
if (existing.rowCount) {
const row = existing.rows[0];
if (row.request_hash !== requestHash) {
return res.status(409).json({ error: "Same key used with different payload" });
}
return res.status(row.status_code).json(row.response_body);
}
await db.query(
"INSERT INTO idempotency_keys (key, request_hash) VALUES ($1, $2)",
[key, requestHash]
);
const order = await createOrderAndCharge(req.body);
const responseBody = { orderId: order.id, status: "paid" };
await db.query(
"UPDATE idempotency_keys SET status_code = $2, response_body = $3 WHERE key = $1",
[key, 201, responseBody]
);
res.status(201).json(responseBody);
});PostgreSQL يدعم ON CONFLICT كطريقة atomic للتعامل مع التصادمات عند الإدخال. في نسخة production، الأفضل تستخدم transaction وقفل واضح أو INSERT ... ON CONFLICT DO NOTHING حتى لا يفوز طلبان بنفس المفتاح تحت الضغط.
الأرقام: قبل وبعد
في سيناريو checkout عنده 50 ألف محاولة دفع يوميًا، ونسبة timeout حوالي 0.3%، عندك 150 طلب محتمل يتكرر يوميًا. لو كل طلب يعمل retry مرتين، فبدون idempotency ممكن يتحول طلب واحد إلى 3 محاولات تنفيذ. مع المفتاح، التنفيذ الفعلي يظل مرة واحدة والـ retries ترجع نفس الرد.
الرقم هنا تقديري لتوضيح المخاطرة، لكنه قريب من اللي بيحصل في تطبيقات الموبايل عند ضعف الشبكة. المكسب: تقلل duplicate side effects من 3 تنفيذات محتملة إلى تنفيذ واحد. الخسارة: جدول إضافي، تخزين ردود مؤقتة، ومنطق تنظيف دوري.
الـ trade-off هنا
- بتكسب أمان retry: العميل يقدر يعيد الطلب بدون خوف من تكرار الدفع أو إنشاء order جديد.
- بتخسر مساحة تخزين: كل مفتاح يحتاج صفًا في قاعدة البيانات. لو عندك مليون طلب يوميًا، احتفظ بالمفاتيح 24 إلى 48 ساعة فقط.
- بتكسب وضوحًا في الأخطاء: لو نفس المفتاح اتبعت ببيانات مختلفة، ترجع 409 بدل تنفيذ غامض.
- بتخسر بساطة الكود: لازم تفكر في transactions وحالات الطلب الذي بدأ ولم يكتمل.
بدل ما تخزن المفتاح للأبد، اعمل job يومي يمسح المفاتيح القديمة. Stripe تذكر أن المفاتيح يمكن حذفها بعد 24 ساعة على الأقل في نظامها. عندك حرية تزيد المدة لو الـ clients عندك ممكن تعمل retry بعد وقت أطول.
متى لا تستخدم هذه الطريقة
لا تستخدم Idempotency Keys مع GET العادي، لأنه لا يغير الحالة أصلًا. لا تستخدمها كبديل للـ unique constraints المهمة، مثل منع تكرار رقم invoice. ولا تعتمد عليها وحدها لو العملية موزعة بين أكثر من خدمة بدون outbox أو transaction boundary واضح.
كذلك، لو العملية غير مهمة وتكرارها غير مؤذٍ، مثل تسجيل analytics event بسيط، التكلفة قد تكون أعلى من المكسب. استخدمها في الأوامر التي لها أثر حقيقي: دفع، إنشاء اشتراك، إرسال رسالة مدفوعة، أو حجز رصيد.
مصادر
- Stripe API Reference: Idempotent requests
- MDN: Crypto.randomUUID()
- PostgreSQL Documentation: INSERT and ON CONFLICT
الخطوة التالية
افتح أخطر endpoint عندك بيعمل side effect، غالبًا payment أو create order، وضيف شرط Idempotency-Key عليه قبل ما تسمح بأي retry تلقائي من الواجهة أو تطبيق الموبايل.