المستوى المطلوب: متوسط. يبدأ الشرح بمثال بسيط يفهمه أي مبتدئ، ثم يتعمّق بتعريف علمي دقيق وكود تنفيذي جاهز للنسخ.
لو العميل ضغط زر الدفع مرتين بسبب بطء الشبكة، ممكن يتخصم منه المبلغ مرتين. الحل مفتاح واحد اسمه Idempotency-Key بيضمن إن الطلب المكرر يتنفّذ مرة واحدة بس. هنا هتطبّقه بكود جاهز في أقل من 20 سطر.
Idempotency: اضغط زر الدفع مرتين، اتخصم مرة واحدة
المشكلة باختصار
الشبكة مش مضمونة. العميل يضغط "ادفع"، الطلب يوصل للسيرفر فعلاً ويتم الخصم، لكن الرد يضيع في الطريق بسبب timeout. المتصفح أو الموبايل بيعيد المحاولة تلقائيًا، فيوصل طلب تاني مطابق. السيرفر مش عارف إن ده نفس الطلب، فيخصم تاني.
ده مش سيناريو نادر. أي عملية فيها side effect حقيقي (خصم فلوس، إنشاء طلب، إرسال إيميل) معرّضة للمشكلة دي. والأخطر إنها بتحصل بصمت، من غير أي رسالة خطأ.
المثال الأول: التذكرة المرقّمة
تخيّل إنك رحت مكتب حكومي وأخدت تذكرة عليها رقم. قدّمت التذكرة للموظف، فنفّذ معاملتك وكتب نتيجتها جنب الرقم في دفتر. بعد شوية رجعت وقدّمت نفس التذكرة بنفس الرقم.
الموظف الشاطر مش هيعيد المعاملة من الأول. هيبص على الرقم في الدفتر، يلاقيه اتخدم قبل كده، فيقولك النتيجة المحفوظة نفسها. أنت خدت نفس النتيجة، وماحصلش خصم تاني.
الرقم اللي على التذكرة هو الـ Idempotency-Key. ودفتر الموظف هو الـ cache اللي بيخزّن نتيجة أول طلب. أي طلب تاني بنفس الرقم بيرجّع النتيجة المخزّنة بدل ما ينفّذ من جديد.
التعريف العلمي الدقيق
عملية idempotent هي العملية اللي تنفيذها مرة واحدة أو عشر مرات بيدّي نفس النتيجة على حالة النظام. حسب معيار HTTP الرسمي (RFC 9110)، ميثودات زي GET وPUT وDELETE معرّفة إنها idempotent بطبيعتها. بينما POST مش idempotent، لأنه بيُنشئ مورد جديد في كل مرة.
المشكلة إن معظم عمليات الدفع والطلبات بتتعمل بـ POST. عشان كده ظهر ترويسة قياسية اسمها Idempotency-Key (فيه مسودة رسمية في IETF بتوصّفها) بتحوّل الـ POST من عملية خطيرة عند الإعادة لعملية آمنة. الفكرة: العميل بيولّد مفتاح فريد لكل عملية (مثلاً UUID v4)، والسيرفر بيستخدم المفتاح ده عشان يميّز الطلب المكرر.
الحل عمليًا: كود جاهز بـ FastAPI و Redis
الكود ده بيطبّق المبدأ. النقطة الحرجة هي استخدام عملية SET ... NX الذرّية في Redis، اللي بتحجز المفتاح لأول طلب بس. ده بيمنع حالة السباق لو وصل طلبان في نفس اللحظة بالظبط.
import json
import redis
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
TTL_SECONDS = 24 * 60 * 60 # 24 ساعة، زي المدة اللي بتستخدمها Stripe
@app.post("/pay")
def pay(body: dict, idempotency_key: str = Header(...)):
cache_key = f"idem:{idempotency_key}"
# SET NX ذرّية: بترجّع True لأول طلب فقط
first = r.set(cache_key, "processing", nx=True, ex=TTL_SECONDS)
if not first:
cached = r.get(cache_key)
if cached == "processing":
# الطلب الأصلي لسه بيتنفّذ، متعملش خصم تاني
raise HTTPException(status_code=409, detail="نفس الطلب لسه بيتنفّذ")
return json.loads(cached) # رجّع نفس نتيجة أول طلب
charge = do_charge(body) # الخصم الحقيقي: بيتنفّذ مرة واحدة بس
response = {"status": "paid", "amount": charge["amount"], "id": charge["id"]}
r.set(cache_key, json.dumps(response), ex=TTL_SECONDS)
return response