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);
});