ما هو الـ Idempotency؟ منع تكرار الطلبات في Laravel APIs
الـ Idempotency يعني أن تكرار نفس الطلب لا يغيّر النتيجة بعد المرة الأولى، وهو ما يمنع خصم المبلغ مرتين عند إعادة المحاولة. إليك الفكرة وتطبيقها في Laravel.
تخيّل أن تطبيق الجوال أرسل طلب دفع، ثم انقطع الاتصال قبل وصول الرد. هل نجح الطلب أم لا؟ العميل لا يعرف، فيعيد المحاولة، وقد ينتهي الأمر بطلبين وخصمين. هذه المشكلة ليست نادرة: كل شبكة تفشل أحيانًا، وقد تعيد الـ Queue تنفيذ الـ jobs الفاشلة، وكل مستخدم قلق يضغط الزر مرتين. في هذا المقال نشرح المفهوم، ونفرّق بين الـ Idempotency في HTTP وبين مفتاح Idempotency-Key، ثم نبني Middleware في Laravel يحميك من التكرار.
ما معنى Idempotency بالضبط؟
المصطلح مأخوذ من الرياضيات، ومعناه في واجهات HTTP أن تكرار طلب مطابق يعطي نفس الأثر المقصود على الخادم كما لو أُرسل مرة واحدة. تنص مواصفة HTTP (RFC 9110، القسم 9.2.2) على أن الطرق التالية idempotent: PUT وDELETE والطرق الآمنة GET وHEAD وOPTIONS وTRACE.
أما POST فليس idempotent بطبيعته، وهنا تكمن المشكلة. المواصفة نفسها تقول إن العميل لا ينبغي أن يعيد تلقائيًا إرسال طلبات غير idempotent إلا إذا كان يعرف أن الطلب آمن للتكرار، وإن الـ proxies يجب ألا تعيد إرسالها تلقائيًا أبدًا.
ملاحظة مهمة: المقصود هو الأثر على الخادم وليس الرد نفسه. فـ DELETE /orders/5 الثاني قد يرجع 404 بدل 204، ومع ذلك يبقى idempotent لأن حالة النظام لم تتغير.
المشكلة العملية: POST الذي يُنفَّذ مرتين
أي عملية تنشئ شيئًا أو تحرّك مالًا عبر POST معرّضة للتكرار: إنشاء طلب، دفع، إرسال رسالة، حجز مقعد. الحل المعتمد في الصناعة هو أن يولّد العميل مفتاحًا فريدًا لكل عملية منطقية ويرسله مع الطلب، فيتعرف الخادم على إعادة المحاولة ويرجع النتيجة المحفوظة بدل تنفيذ العملية من جديد.
هذا ما تفعله Stripe عبر الـ header بالاسم Idempotency-Key. بحسب توثيقها: تُحفظ حالة الرد وجسمه للطلب الأول بهذا المفتاح، وأي إعادة بنفس المفتاح تعيد الرد المحفوظ (حتى لو كان خطأ 500 في الإصدار v1 من API)، والمفتاح يصل إلى 255 حرفًا، ويُنصح بـ UUID v4، وقد تُحذف المفاتيح بعد 24 ساعة على الأقل، وإذا أُعيد المفتاح مع معاملات مختلفة يرجع خطأ.
ما الذي يقوله المعيار الناشئ؟
هناك مسودة IETF من مجموعة httpapi بعنوان "The Idempotency-Key HTTP Header Field" (الإصدار 08 بتاريخ 22 أكتوبر 2026، وما زالت Internet-Draft وليست RFC). أبرز ما فيها:
- الـ header قيمته String، ويولّده العميل، ويجب ألا يُعاد استخدامه مع جسم طلب مختلف.
- يُنصح بـ UUID أو معرّف عشوائي مشابه، وتحذّر المسودة من المفاتيح ضعيفة العشوائية لأنها قد تكشف ردودًا مخزنة لعملاء آخرين.
- 400 إذا كانت العملية تتطلب المفتاح وهو غير موجود.
- 422 إذا أُعيد المفتاح مع محتوى مختلف.
- 409 إذا وصلت إعادة المحاولة والطلب الأصلي ما زال قيد المعالجة.
سنتبع هذه الرموز في التطبيق أدناه، مع التنبيه أنها مسودة قد تتغير.
تطبيق Idempotency-Key في Laravel
الفكرة: قفل ذري (atomic lock) لمنع تنفيذ طلبين بنفس المفتاح في وقت واحد، ثم بصمة للجسم لاكتشاف إعادة استخدام المفتاح بمحتوى مختلف، ثم تخزين الرد في الـ cache. نستخدم Cache::lock الموثّق في Laravel، وهو يتطلب driver يدعم الأقفال (مثل redis أو database) ومخزنًا مركزيًا مشتركًا بين كل الخوادم.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EnsureIdempotency
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (! $key || ! Str::isUuid($key)) {
return response()->json(['message' => 'Idempotency-Key (UUID) header is required.'], 400);
}
$fingerprint = hash('sha256', $request->getContent());
$cacheKey = 'idem:'.$request->user()->id.':'.hash('sha256', $request->method().$request->path().$key);
$lock = Cache::lock($cacheKey.':lock', 30);
if (! $lock->get()) {
return response()->json(['message' => 'A request with this key is still being processed.'], 409);
}
try {
if ($saved = Cache::get($cacheKey)) {
if ($saved['fingerprint'] !== $fingerprint) {
return response()->json(['message' => 'Idempotency-Key was reused with a different payload.'], 422);
}
return response($saved['body'], $saved['status'], ['Content-Type' => $saved['type'], 'Idempotent-Replayed' => 'true']);
}
$response = $next($request);
if ($response->getStatusCode() < 500) {
Cache::put($cacheKey, [
'fingerprint' => $fingerprint,
'status' => $response->getStatusCode(),
'body' => $response->getContent(),
'type' => $response->headers->get('Content-Type', 'application/json'),
], now()->addHours(24));
}
return $response;
} finally {
$lock->release();
}
}
}
ثم سجّله كاسم مستعار في bootstrap/app.php وطبّقه على المسارات الحساسة فقط:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['idempotent' => \App\Http\Middleware\EnsureIdempotency::class]);
})
// routes/api.php
Route::post('/orders', [OrderController::class, 'store'])
->middleware(['auth:sanctum', 'idempotent']);
لاحظ أننا ربطنا المفتاح بالمستخدم والمسار والطريقة، حتى لا يتصادم مفتاحان من مستخدمَين مختلفين.
اختبار السلوك
اكتب اختبارًا يرسل الطلب نفسه مرتين بنفس المفتاح ويتأكد أن الأثر الجانبي حدث مرة واحدة فقط. يدعم driver الـ array الأقفال، فيصلح للاختبارات:
public function test_replayed_request_creates_one_order(): void
{
$user = User::factory()->create();
$headers = ['Idempotency-Key' => (string) Str::uuid()];
$payload = ['sku' => 'A1', 'qty' => 1];
$first = $this->actingAs($user, 'sanctum')->postJson('/api/orders', $payload, $headers);
$second = $this->actingAs($user, 'sanctum')->postJson('/api/orders', $payload, $headers);
$second->assertStatus($first->status())->assertHeader('Idempotent-Replayed', 'true');
$this->assertSame(1, Order::count());
}
أضف اختبارين آخرين: واحدًا يتحقق من 422 عند تغيير الجسم مع نفس المفتاح، وآخر من 400 عند غياب الـ header.
مفاهيم خاطئة شائعة
- "Idempotent تعني أن الرد متطابق دائمًا." لا، المقصود الأثر على الخادم. الرد قد يختلف (204 ثم 404 في
DELETE). - "يكفي تعطيل الزر في الواجهة." هذا يخفف المشكلة فقط؛ انقطاع الشبكة وإعادة المحاولة التلقائية في المكتبات يحدثان خارج الواجهة.
- "المفتاح يمكن اشتقاقه من بيانات الطلب." الأفضل أن يولّده العميل عشوائيًا لكل عملية منطقية؛ المفتاح المشتق من بيانات مثل البريد قد يمنع طلبًا مشروعًا ثانيًا بنفس البيانات، وتنصح Stripe بتجنب البيانات الحساسة في المفاتيح.
- "أضعه على كل المسارات." لا حاجة له في
GETوPUTوDELETEلأنها idempotent أصلًا؛ ركّز على الـPOSTالذي له أثر جانبي مهم.
محاذير لا تتجاهلها
- هذا الـ Middleware نقطة بداية وليس ضمانًا مطلقًا. إن انهارت العملية بعد تنفيذ الأثر الجانبي (كالخصم) وقبل حفظ الرد، فلن يُسجَّل شيء، وستتكرر العملية عند إعادة المحاولة. للعمليات المالية احمِ المنطق نفسه أيضًا بقيد
uniqueفي قاعدة البيانات أو بتمرير المفتاح إلى مزوّد الدفع. - مدة القفل 30 ثانية في المثال. إن استغرق الطلب أطول فقد ينتهي القفل قبل اكتماله، فاضبطها حسب عمليتك.
- قرار الأخطاء 5xx: لا نحفظها هنا فيتمكن العميل من إعادة المحاولة، بينما Stripe v1 تحفظها وتعيدها. كلا الخيارين مقبول، لكن اختر عن وعي.
- الـ cache قد يُفرَّغ: إن كان الـ cache قابلًا للإفراغ، فالأفضل تخزين السجلات في جدول قاعدة بيانات مخصص للعمليات المهمة.
- الـ Jobs أيضًا: الـ Queue تعيد تشغيل الـ jobs عند الفشل، فصمّم الـ job نفسه ليتحمّل التكرار (تحقق من الحالة قبل التنفيذ).
الخلاصة
- الطرق
GETوPUTوDELETEidempotent بحكم المواصفة، أماPOSTفيحتاج حماية صريحة. - اجعل العميل يولّد UUID لكل عملية منطقية ويرسله في
Idempotency-Key. - على الخادم: قفل ذري + بصمة للجسم + تخزين الرد، مع 400 و409 و422 للحالات المناسبة.
- لا تعتمد على الـ cache وحده في المال: أضف قيودًا في قاعدة البيانات.
ابدأ بمسار واحد حساس في تطبيقك (إنشاء الطلب أو الدفع)، وأضف عليه الـ Middleware واختبر إرسال الطلب مرتين بنفس المفتاح.