لقد استدعى وكيلك نقطة نهاية الدفع. تم إرسال الطلب، وتمت عملية الخصم، ثم انتهت مهلة الاستجابة في طريق العودة. لم ير الوكيل أبدًا رمز `200`، لذلك قام بما أمرته به عند الفشل: أعاد المحاولة. الآن تم خصم مبلغ مرتين من العميل، ولا يوجد شيء في سجلاتك يبدو كخطأ.
هذه هي طريقة الفشل التي تفصل الوكلاء عن عملاء واجهة برمجة التطبيقات (API) العاديين. يرى الإنسان الذي ينقر على "دفع" مرة واحدة مؤشر تحميل وينتظر. بينما يرى الوكيل في حلقة إعادة المحاولة صمتًا ويحاول مرة أخرى، أحيانًا ثلاث أو أربع مرات متتالية، أسرع مما يستطيع أي شخص. كل سياسة إعادة محاولة تضيفها لجعل الوكيل أكثر موثوقية تزيد أيضًا من احتمالية تكرار عمليات الكتابة. الحل هو مبدأ الثبات (idempotency): جعل الطلب المتكرر ينتج نفس نتيجة الطلب الفردي.
يغطي هذا الدليل ما يعنيه مبدأ الثبات (idempotency) على مستوى HTTP، وكيفية إنشاء مفاتيح يمكن للوكيل إعادة استخدامها بالفعل، وما يجب على الخادم تخزينه لتكريمها، وكيفية اختبار كل ذلك قبل أن يتم محاسبة عميل حقيقي مرتين. إذا لم تقرأ مقالنا الأساسي حول لماذا تتعطل وكلاء الذكاء الاصطناعي في بيئة الإنتاج، فإن عمليات الكتابة المكررة هي نمط الفشل الكامن وراء معظم تقارير "الوكيل فعل ذلك مرتين".
Apidog يظهر في النصف الخاص بالاختبار من هذا. مبدأ الثبات هو شيء تقوم ببنائه في واجهة برمجة التطبيقات (API) الخاصة بك وفي طبقة أدوات وكيلك. ما تحتاجه بعد ذلك هو طريقة لإرسال نفس الطلب مرتين وإثبات أن الطلب الثاني لم يغير شيئًا، وهو اختبار يمكنك حفظه وتشغيله في التكامل المستمر (CI).
لماذا يكسر الوكلاء مبدأ الثبات (idempotency) أكثر من البشر
ثلاثة أمور تتعلق بحركة مرور الوكلاء تجعل التكرارات شائعة.
الأول هو حجم إعادة المحاولة. تقوم أطر عمل الوكلاء بإعادة المحاولة بقوة افتراضيًا لأن فشل الشبكة العابر هو السبب الأكثر شيوعًا لتوقف التشغيل. دليلنا حول استعادة الأخطاء لوكلاء الذكاء الاصطناعي يستعرض استراتيجيات التراجع وقواطع الدائرة، وكل تقنية فيه تزيد من عدد المرات التي يصل فيها طلب معين إلى خادمك.
الثاني هو غموض انتهاء المهلة. عندما تنتهي مهلة الطلب، لا يعرف العميل شيئًا عما إذا كان الخادم قد عالجه. يمكن أن يشير رمز `504` من وكيل (proxy) إلى أن عملية الكتابة لم تحدث أبدًا أو أنها حدثت وفقدت الاستجابة. عادةً ما يتحقق البشر قبل إعادة المحاولة. لا يفعل الوكلاء ذلك عادةً، لأن "التحقق أولاً" هو استدعاء أداة إضافي يجب على النموذج أن يقرر القيام به.
الثالث هو الحلقة. قد يقوم الوكيل الذي يفشل في مهمة بإعادة تشغيل المهمة بأكملها، وليس فقط الخطوة الفاشلة. إذا أنشأت الخطوة الأولى طلبًا وفشلت الخطوة الرابعة، فإن إعادة التشغيل الساذجة تنشئ طلبًا ثانيًا. هذا هو المكان الذي تختلف فيه الوكلاء متعددة الخطوات بشكل حاد عن البرنامج النصي: حدود إعادة المحاولة غامضة، والنموذج، وليس التعليمات البرمجية الخاصة بك، هو الذي يقرر أين تبدأ.
اجمع هذه الأمور معًا وستحصل على شكل المشكلة. ليس الأمر أن الوكلاء يرسلون طلبات سيئة. بل يرسلون طلبات صحيحة أكثر من مرة.
ماذا يضمن مبدأ الثبات (Idempotency) فعليًا
تكون العملية ثابتة (idempotent) عندما يكون لتنفيذها عدة مرات نفس التأثير الذي يكون لتنفيذها مرة واحدة. يتم تعريف `GET` و `PUT` و `DELETE` على أنها ثابتة في RFC 9110، مواصفات دلالات HTTP. `POST` ليس كذلك، وهذا هو بالضبط سبب ميل العمليات الخطيرة إلى أن تكون استدعاءات `POST`: إنشاء طلب، إرسال رسالة، بدء تحويل.
توضيحان يوفران الكثير من الارتباك.
الثابت (idempotent) ليس هو نفسه الآمن (safe). الطريقة الآمنة لا تغير شيئًا. `DELETE` ثابت ولكنه مدمر: استدعاؤه خمس مرات يترك المورد محذوفًا، تمامًا كما لو تم استدعاؤه مرة واحدة، لكن المورد يظل مختفيًا. يحتاج الوكلاء إلى فرز كلتا الخاصيتين بشكل منفصل، وهذا هو ما يقدمه مقالنا حول مفاتيح واجهة برمجة التطبيقات للوكلاء بأقل الامتيازات من جانب بيانات الاعتماد.
الثابت (idempotent) ليس هو نفسه الاستجابة المتطابقة أيضًا. قد يعيد الاستدعاء الثاني النتيجة المخزنة للأول، وقد يعيد رمز حالة مختلفًا. ما يجب ألا يتغير هو الحالة على الخادم. خصم واحد. طلب واحد. بريد إلكتروني واحد.
مفاتيح الثبات (Idempotency keys): النمط الذي يجعل طلبات POST آمنة
الحل القياسي هو مفتاح يتم إنشاؤه بواسطة العميل ويُرسل مع الطلب. يسجل الخادم المفتاح جنبًا إلى جنب مع النتيجة، وأي طلب لاحق يحمل نفس المفتاح يعيد النتيجة المسجلة بدلاً من القيام بالعمل مرة أخرى.
Stripe شاعت استخدام رأس `Idempotency-Key`، ولا يزال توثيق Stripe حول مبدأ الثبات هو أوضح وصف للدلالات. هناك أيضًا جهد من قبل IETF لتوحيدها كـ حقل رأس Idempotency-Key، وهو أمر يستحق القراءة قبل أن تبتكر اسم رأس خاص بك.
يبدو الطلب كالتالي:
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json
{
"amount": 4900,
"currency": "usd",
"customer_id": "cus_8812",
"description": "Pro plan, August"
}
المفتاح هو UUID. ليس له أي معنى للخادم سوى "هذه هي نفس العملية المنطقية". يخزنه الخادم، إلى جانب بصمة لبيانات الطلب (request body) والاستجابة التي أنتجها.
إنشاء مفتاح يمكن للوكيل إعادة استخدامه
هنا يخطئ معظم تطبيقات الوكلاء. إذا قامت غلاف الأداة (tool wrapper) بإنشاء UUID جديد في كل استدعاء، فإن المفتاح يتغير في كل إعادة محاولة، ولا يفعل مبدأ الثبات شيئًا. يجب أن يكون المفتاح مرتبطًا بالعملية المنطقية، وليس بمحاولة HTTP.
القاعدة: قم بإنشاء المفتاح عندما يقرر الوكيل تنفيذ إجراء، واحتفظ به لكل إعادة محاولة لهذا القرار.
import uuid
class PaymentTool:
def __init__(self, client):
self.client = client
self._keys = {}
def charge(self, task_id, step_id, amount, customer_id):
# One key per (task, step). Retries of the same step reuse it.
op = f"{task_id}:{step_id}"
if op not in self._keys:
self._keys[op] = str(uuid.uuid4())
return self.client.post(
"/v1/payments",
headers={"Idempotency-Key": self._keys[op]},
json={"amount": amount, "customer_id": customer_id},
)
يعمل المفتاح المحدد (deterministic key) أيضًا، وهو يبقى سليمًا عند إعادة تشغيل العمليات، وهو ما لا تفعله القاموس في الذاكرة:
import hashlib
def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
استمد المفتاح من تشغيل المهمة والخطوة، وليس أبدًا من طابع زمني أو قيمة عشوائية يتم إعادة إنشائها لكل محاولة. إذا أعاد الوكيل تشغيل المهمة بأكملها وكان ينوي بصدق إنشاء خصم جديد، يتغير معرف المهمة وبالتالي يتغير المفتاح. وهذا هو السلوك الذي تريده.
ما يجب على الخادم فعله
يتطلب التعامل مع رأس الطلب بشكل صحيح أكثر من مجرد بحث. يقوم التنفيذ الفعال بأربعة أمور:
- عند الوصول، حاول المطالبة بالمفتاح. أدخله في جدول بحد فريد قبل القيام بأي عمل. إذا فشل الإدخال، فإن محاولة أخرى تملكه.
- إذا كان المفتاح موجودًا وتختلف بصمة الطلب المخزنة، ارفض بالرمز `422`. نفس المفتاح مع جسم طلب مختلف يعني وجود خطأ في العميل، وإرجاع النتيجة القديمة بصمت سيخفي هذا الخطأ.
- إذا كان المفتاح موجودًا ولا تزال المحاولة الأولى قيد التنفيذ، أرجع `409` حتى يتراجع المتصل بدلاً من التسابق.
- عند انتهاء العمل، قم بتخزين رمز الحالة وجسم الاستجابة مقابل المفتاح، ثم أعدهما لكل استدعاء لاحق.
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
state TEXT NOT NULL, -- in_progress | completed
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
حدد تاريخ انتهاء صلاحية. تغطي الأربع وعشرون ساعة أي نافذة إعادة محاولة واقعية، والاحتفاظ بالمفاتيح إلى الأبد يحول الجدول إلى عبء. تقوم Stripe بإلغاء صلاحية المفاتيح بعد 24 ساعة، وهو افتراضي معقول يمكن نسخه.
اختبار أن الاستدعاء الثاني لا يغير شيئًا
بناء مبدأ الثبات هو نصف العمل. وإثبات أنه فعال هو النصف الآخر، وهو النصف الذي يتم تخطيه، لأن المسار السعيد يبدو متطابقًا سواء كانت الميزة تعمل أم لا.
الاختبار سهل الوصف: أرسل الطلب، التقط النتيجة، أرسل نفس الطلب بالضبط مرة أخرى، وتأكد أن الخادم لم يقم بالعمل مرتين. الجزء الصعب هو التأكيد الأخير، لأن الاستجابة وحدها لن تخبرك بذلك. عمليتا خصم ناجحتان تعيدان كلاهما `200`.
لذلك، تحقق من الحالة، وليس من الاستجابة:
- يتطابق جسم الاستجابة الثاني مع الأول، بما في ذلك معرف المورد (resource ID). يعني المعرف الجديد أنه تم إنشاء مورد جديد.
- يعيد طلب `GET` متابعة على المجموعة سجلًا واحدًا، وليس اثنين.
- أي عداد أو رصيد تحرك مرة واحدة فقط.
في Apidog يمكنك ربط هذا كسيناريو اختبار: الخطوة الأولى ترسل طلب `POST` بمفتاح `Idempotency-Key` ثابت، الخطوة الثانية تكرره، والخطوة الثالثة تسرد المورد وتؤكد العدد. احفظ معرف الاستجابة من الخطوة الأولى في متغير وتأكد أن الخطوة الثانية تعيد نفس القيمة. نظرًا لأن السيناريو بأكمله مخزن، فإنه يعمل في التكامل المستمر (CI) عند كل تغيير في مسار الدفع، وهو المكان الذي تظهر فيه التراجعات (regressions) فعليًا. تنتقل نفس التقنية إلى الأنماط الأوسع في دليل اختبار عقود واجهة برمجة التطبيقات الخاص بنا.

حالتان إضافيتان تستحقان التغطية، لأنهما تكشفان عن أخطاء حقيقية:
- نفس المفتاح، جسم طلب مختلف. توقع `422`، وليس نجاحًا صامتًا.
- التكرارات المتزامنة. أطلق كلا الطلبين في نفس الوقت وتأكد من أن واحدًا فقط ينجح. هذا يكشف عن القيد الفريد المفقود الذي لن يكشفه اختبار تسلسلي أبدًا.
المحاكاة (Mocking) تساعد هنا أيضًا. إذا كنت لا تزال تبني الوكيل وواجهة برمجة تطبيقات الدفع غير موجودة بعد، فقم بمحاكاتها باستجابة مدركة لمبدأ الثبات (idempotency-aware) بحيث يتم تدريب منطق إعادة المحاولة للوكيل مبكرًا. يقدم مقالنا حول لماذا يجب على الوكلاء استخدام المحاكاة بدلاً من بيئة الإنتاج حالة أوسع لهذه العادة.
عندما لا يمكنك إضافة مفتاح
في بعض الأحيان لا تكون واجهة برمجة التطبيقات (API) ملكك وليس لديها دعم لمبدأ الثبات. لا يزال لديك خيارات، بترتيب تفضيل تقريبي.
- اجعل العملية ثابتة (idempotent) بطبيعتها. طلب `PUT` إلى مسار مورد يختاره العميل هو ثابت بطبيعته: `PUT /orders/{client_order_id}`. إذا كنت تتحكم في تصميم واجهة برمجة التطبيقات، ففضل هذا على `POST` بالإضافة إلى رأس الطلب. لا يحتاج إلى جدول إضافي.
- تحقق قبل الكتابة. اجعل الوكيل يستعلم عن سجل موجود بنفس المفتاح الطبيعي قبل إنشاء سجل جديد. هذا أضعف، لأن السباق بين التحقق والكتابة لا يزال بإمكانه إنتاج سجلين، ولكنه يزيل حالة انتهاء المهلة الشائعة.
- إزالة التكرار في الطرف المتلقي (downstream). إذا كانت عملية الكتابة رسالة أو حدثًا، فضع منطق إزالة التكرار في المستهلك. أرفق معرف رسالة ثابتًا واجعل المستهلك يتجاهل التكرارات. هذه ممارسة قياسية في الأنظمة المعتمدة على الأحداث وتتوافق مع الإرشادات في دليل الـ webhooks الموثوقة الخاص بنا.
- بوابة الإجراء. للعمليات التي لا رجعة فيها حقًا ولا يمكن جعلها ثابتة (idempotent)، ضع شخصًا أمامها. هذا هو نمط بوابة الموافقة (approval-gate) من مقالنا حول حواجز حماية وكلاء الذكاء الاصطناعي، وهو الإجابة الصحيحة عندما تكون تكلفة التكرار مرتفعة بما فيه الكفاية.
اعرف أي عملية تشغيل قامت بماذا
يوقف مبدأ الثبات التكرار. لكنه لا يخبرك بأي محاولة تم إنشاء السجل، وهذا هو السؤال الذي ستُسأل عنه بعد وقوع حادث.
احتفظ بهوية التشغيل (run identity) مرفقة بالعمل. عندما يكون الوكيل هو خدمتك الخاصة، فهذا يعني معرف المهمة (task ID) ومعرف الخطوة (step ID) من اشتقاق المفتاح أعلاه، يتم تسجيلهما مع كل محاولة. عندما يكون الوكيل بيئة تشغيل برمجية تنفذ عملًا معينًا، عادةً ما تحتفظ المنصة بذلك لك: في Sharkly، يتم إرفاق كل عملية تشغيل بالمهمة التي جاءت منها، مع تخزين حالة التنفيذ والنتيجة جنبًا إلى جنب مع سلسلة التعليقات، بحيث يمكن تتبع عملية كتابة مكررة إلى عملية تشغيل محددة بدلاً من إعادة محاولة مجهولة.

قائمة مراجعة قبل النشر
- تتطلب كل أداة غير ثابتة (non-idempotent) يمكن للوكيل استدعاؤها مفتاح ثبات (idempotency key)، وترفض غلاف الأداة الإرسال بدونه.
- تُشتق المفاتيح من المهمة والخطوة، وليس من المحاولة.
- يطالب الخادم بالمفتاح قبل القيام بالعمل، وليس بعده.
- نفس المفتاح مع حمولة (payload) مختلفة يعيد خطأ بدلاً من الاستجابة المخزنة مؤقتًا.
- يتم التعامل مع التكرارات المتزامنة بواسطة قيد قاعدة بيانات (database constraint)، وليس بتوقيت التطبيق.
- يثبت اختبار محفوظ أن الاستدعاء الثاني لا يغير شيئًا، ويتم تشغيله في التكامل المستمر (CI).
- تنتهي صلاحية المفاتيح وفق جدول زمني ويتم تنظيف الجدول.
اعمل على هذه القائمة وستتوقف قصة الخصم المزدوج عن أن تكون ممكنة، مما يعني أن سياسة إعادة المحاولة الخاصة بك يمكن أن تصبح أكثر قوة بدلاً من أن تكون أقل. هذه هي المكافأة الحقيقية: مبدأ الثبات هو ما يسمح لك بجعل الوكيل مرنًا دون جعله خطيرًا.
أسئلة مكررة
هل أحتاج إلى مفاتيح الثبات (idempotency keys) للأدوات التي تعتمد على القراءة فقط؟ لا. طلبات `GET` ثابتة وآمنة بالفعل، لذا فإن إعادة محاولة إحداها يكلفك القليل من زمن الاستجابة (latency) ولا شيء آخر. احتفظ بالمفاتيح للمكالمات التي تنشئ أو تفرض رسومًا أو ترسل أو تغير الحالة بأي طريقة أخرى.
أين يجب إنشاء المفتاح، في الوكيل أم في غلاف الأداة (tool wrapper)؟ في غلاف الأداة، بناءً على معرفات المهمة والخطوة الخاصة بالوكيل. السماح للنموذج بإنشاء المفتاح خطأ: فالنماذج تعيد إنشاء القيم عند إعادة المحاولة ويمكن أن تتسبب في تعارضات عبر المهام.
ما هو رمز الحالة الذي يجب أن يعيده الطلب المتكرر؟ أرجع الحالة المخزنة من الاستدعاء الأصلي، لذا فإن طلب `POST` ثانٍ أعاد في البداية `201` سيعيد `201` مرة أخرى بنفس محتوى الاستجابة (body). تضيف بعض واجهات برمجة التطبيقات رأسًا مثل `Idempotent-Replay: true` لتمييز التكرار، وهو أمر مفيد لتصحيح الأخطاء وغير ضار للعملاء الذين يتجاهلونه.
كم من الوقت يجب الاحتفاظ بالمفاتيح؟ تغطي الأربع وعشرون ساعة تقريبًا كل نافذة إعادة محاولة. الاحتفاظ لفترة أطول نادرًا ما يساعد ويزيد حجم الجدول بلا حدود. إذا قام العميل بإعادة المحاولة بعد هذه الفترة، فتعامل معها كعملية جديدة.
هل هذا يحل محل المعاملات (transactions)؟ لا. توقف مفاتيح الثبات الطلبات المكررة من إنتاج تأثيرات مكررة. بينما تحافظ المعاملات على طلب واحد ذريًا (atomic). تحتاج إلى كليهما، ويجب كتابة المطالبة بالمفتاح في نفس المعاملة التي يتم فيها العمل كلما سمحت قاعدة بياناتك بذلك.
كيف يمكنني اختبار ذلك بدون مزود دفع حقيقي؟ وجه الوكيل إلى محاكاة (mock) تطبق دلالات المفتاح، بما في ذلك `422` عند عدم تطابق الحمولة (payload). يغطي دليلنا حول اختبار وكلاء الذكاء الاصطناعي مقابل واجهات برمجة التطبيقات المحاكية الإعداد، وقم بتنزيل Apidog إذا كنت تريد المحاكاة واختبار إعادة المحاولة في نفس المشروع.
