رد 404 الوهمي: عندما يعجز الطلب عن رؤية ما تبحث عنه
Jaafar Abazid
كان الفحص سليماً. وهذه هي النقطة التي تستحق التوقف عندها.
كنت أراجع البيانات المنظمة Structured Data في هذا الموقع، وتحديداً كتلة sameAs التي تخبر محركات البحث بالحسابات الأخرى التابعة للشركة نفسها أو للشخص نفسه. قد يبدو ذلك تفصيلاً صغيراً، لكنه يحتاج إلى دقة كاملة، لأن وجود رابط يشير إلى صفحة غير موجودة ليس مجرد خطأ عابر، بل ادعاء خاطئ عن الهوية، منشور على كل صفحة في الموقع.
كان أحد هذه الروابط يشير إلى صفحة على Crunchbase. يعيد Crunchbase الحالة 403 لأي طلب لا يأتي من متصفح، لذلك لم يكن بإمكان curl حسم الأمر، وبقي هذا الرابط في الكود لأسابيع مرفقاً بتعليق يقول، وبكل أمانة: “غير مؤكَّد، يحتاج نظرة في متصفّح حقيقي”.
لذلك فتحته في متصفح حقيقي. وكانت النتيجة: الصفحة غير موجودة.
لم تكن شاشة تحقق. ولم تكن صفحة تطلب تسجيل الدخول. بل كانت صفحة يتصدرها عنوان h1 يحمل النص الصفحة غير موجودة، يتبعه نص يتحدث عن رواد فضاء لم يتمكنوا من العثور على الصفحة. بل إنني كنت قد كتبت الفحص بعناية: كان يبحث عن العبارات المعتادة التي تدل على حظر الروبوتات، مثل just a moment، وaccess denied، وare you a robot، وأبلغ بأن أياً منها غير موجود. نتيجة سلبية واضحة. لذلك حذفت الرابط من كيان الشخص Person، وبدأت أكتب رسالة الـ commit التي تشرح سبب إزالة رابط ميت.
لكن الصفحة موجودة بالفعل. فهي تضم صورة، وسيرة ذاتية، ومعلومات عن المؤهلات العلمية، وحقل موقع إلكتروني يشير إلى هذا الموقع. ومع ذلك، يعرض Crunchbase للزوار غير المسجلين صفحة توحي بأن الملف الشخصي غير موجود.
كنت قد بنيت أداة لاكتشاف التضليل، وشغّلتها، وحصلت منها على إشارة تؤكد أن كل شيء سليم، ومع ذلك تعرضت للتضليل!
شكل المشكلة
إن غياب الدليل ليس دليلاً على الغياب. وهذا صحيح، لكنه ليس ما حدث هنا تماماً. فهو يوحي بأن الخطر يكمن في فحص يعود بلا نتيجة، والنتائج الفارغة تبدو، على الأقل، فارغة.
ما حدث في الواقع كان أسوأ. الأداة أعادت نتيجة صحيحة، لكنها كانت تجيب عن السؤال الخطأ. لم تكن قادرة على رؤية الملفات الشخصية، لذلك قدّمت تقريراً صحيحاً عن الصفحة التي وصلتها، والتي صادف أنها صفحة تتحدث عن عدم العثور على الأشياء. لم يحدث أي خطأ. ولم تنتهِ أي عملية بمهلة زمنية. ولم يكن هناك أي غموض يدفعني إلى التوقف أو إعادة النظر.
وحين انتبهت لذلك، راجعت ما أنجزته خلال الأسبوع، فوجدت النمط نفسه قد تكرر خمس مرات أخرى.
الحقل الذي يقيس شيئاً محاذياً
أردت أن أعرف إن كان الملف الشخصي على GitHub يحتوي على ما يستحق الإشارة إليه. فأعادت الواجهة البرمجية API ما يلي:
"public_repos": 0
صفر. إذاً لا شيء هناك، ويجب أن يشير الرابط إلى حساب المؤسسة بدلاً من الحساب الشخصي.
لكن الحقل public_repos لا يحصي إلا المستودعات التي يمتلكها الحساب. ولا يخبر شيئاً عما تعرضه صفحة الملف الشخصي نفسها. فعند فتح الملف الشخصي دون تسجيل الدخول، تظهر ستة مستودعات مثبّتة Pinned Repositories تابعة للمؤسسة، إضافة إلى مخطط مساهمات Contribution Graph يظهر 68 يوماً من النشاط، لأن جميع المساهمات التي أُرسلت باستخدام بريد إلكتروني موثَّق تُنسب إلى الحساب، بغض النظر عن المستودع الذي أُرسلت إليه.
كان الرقم صحيحاً. لكنه كان يجيب عن سؤال لم أطرحه.
نقطة النهاية التي تختلف مع نفسها
الأداة نفسها، والواجهة البرمجية API نفسها، وخلال الأسبوع نفسه. كنت أراجع تراخيص مستودعات المشاريع في المؤسسة:
gh repo list myorg --json name,licenseInfo
# → licenseInfo: null, for every repository
خمسة مستودعات، وكلها ظهرت بلا ترخيص عند الفحص. وهذه مشكلة حقيقية لو كانت الأمر صحيحاً، لأن المستودع العام Public repository الذي لا يحمل ترخيصاً يكون، بشكل افتراضي، خاضعاً لقاعدة “جميع الحقوق محفوظة”، وهو عكس المقصود من أي مشروع مفتوح المصدر.
ثم جرّبت الاستعلام عن كل مستودع على حدة:
gh api repos/myorg/some-plugin --jq .license.spdx_id
# → "GPL-2.0"
وحصلت على نفس النتيجة مع جميع المستودعات. يوجد ملف LICENSE في جذر كل مستودع، وقد اكتشفه GitHub وأبلغ عنه بشكل صحيح. لكن نقطة النهاية الخاصة بالقائمة List Endpoint ونقطة النهاية الخاصة بالمستودع الواحد Item Endpoint تعطيان إجابتين مختلفتين عن الحقل نفسه. والمفارقة أن نقطة نهاية القائمة هي الأسهل استخداماً، ولذلك فهي أول ما تلجأ إليه عندما تريد التحقق من خمسة مستودعات دفعة واحدة.
القناة التي لا توجد إلا للبشر
قضيت ظهيرة كاملة مقتنعاً بأن أداة التحليلات Analytics غير مثبّتة، لأن هذا الأمر لم يعثر على شيء:
curl -s https://example.com | grep -c 'beacon.min.js'
# → 0
وكان هناك سببان مستقلان، وكل واحد منهما وحده يكفي لتفسير النتيجة. فمزود الخدمة لا يحقن الشيفرة إلا عندما يعتقد أن الطلب صادر من متصفح حقيقي، ولذلك لا يرى curl شيئاً. ثم إن تسجيل الزيارة يُرسل إلى مسار على النطاق نفسه same origin، لا إلى نطاق مزود التحليلات، لذلك فإن البحث في HTML عن اسم نطاق مزود الخدمة لن يعثر على شيء، حتى عندما تكون الزيارات تُسجَّل بشكل صحيح تماماً.
نقطتان عمياوان اجتمعتا معاً، وكانت النتيجة المرئية صفراً يبدو واثقاً تماماً.
عملية الكتابة التي لا تصل إلى أي مكان
هذه كلّفتني أكثر من غيرها.
يحتفظ نظام إدارة المحتوى لدينا بإذن File System Access للوصول إلى المستودع، يمنحه المتصفح مرة واحدة. لكن هذا الإذن قد يعود إلى الحالة denied دون أن يلاحظ المحرر. وعندما يحدث ذلك، يواصل المحرر Text editor قبول النص، ويعطّل زر الحفظ بعد الضغط عليه، ويعرض المستند على أنه محفوظ. لكن شيئاً لا يُكتب على القرص.
ترجمة عربية خضعت للمراجعة بالكامل، تضم ثمانيةً وأربعين سطراً معدلاً، لم يكن لها وجود في أي مكان سوى داخل تبويب في المتصفح.
وفي الأسبوع نفسه، حدث أمر يكاد يكون مطابقاً على إحدى المنصات. عدّلت النص البديل Alt text لإحدى الصور، ثم حفظته، لكن الصفحة المنشورة استمرت في عرض النص القديم، رغم أن الاستجابة كانت تحمل no-cache, must-revalidate، ورغم إجراء عمليتي جلب مع تجاوز الذاكرة المؤقتة Cache-Busting، لذلك لم يكن التخزين المؤقت هو التفسير. فالمنصة تربط النص البديل بـالصورة المرفوعة نفسها، لا بحقل قابل للتعديل. وتعديل الحقل لا يغيّر شيئاً. أما إعادة رفع الصورة، فهي عملية الكتابة الوحيدة التي تصل فعلاً.
في كلتا الحالتين، أبلغت الواجهة بأن العملية نجحت. لكن النتيجة الفعلية قالت غير ذلك.
نجاح ليس بنجاح
gh release create v1.0.0 --notes-from-tag --verify-tag
# → https://github.com/myorg/repo/releases/tag/v1.0.0
عاد رابط. والإصدار موجود بالفعل. لكن عنوانه null، بينما تحمل جميع الإصدارات الأخرى في المؤسسة عنواناً مطابقاً لوسمها tag. أي أن الإصدار الوحيد الذي أُنشئ باتباع الأمر الموثق هو الإصدار الوحيد الذي لا يطابق بقية الإصدارات.
لم يفشل الأمر. لقد نفّذ شيئاً مختلفاً قليلاً عما فعلته جميع الإصدارات السابقة، ثم أبلغ عن النجاح نفسه في كلتا الحالتين.
ورسالة الخطأ التي قرأتها على أنها بيانات
هذه أصغرها، لكنها المفضلة لدي، لأنه لا مكان فيها للاختباء.
جلبت pull الشيفرة المصدرية لإحدى الإضافات plugin كي أعدّ شيئاً فيها، لكنني استخدمت اسم ملف خاطئ. فأعادت الواجهة البرمجية API رسالة خطأ بصيغة JSON. قام برنامجي بعدّ الأحرف في الاستجابة، ولم يجد الحرف الذي كنت أبحث عنه، فأبلغ بأن الملف سليم.
وكان القياس دقيقاً تماماً، لكنه كان يقيس رسالة 404.
ولو تصرفت بناءً على تلك النتيجة، لكنت حذفت سطراً من ملف اختبار، مع أن الغرض الوحيد من وجود ذلك السطر هو أن يحتوي على الحرف الذي كنت أعدّه.
ما الذي يصطاد هذه الحالات
ليس الحرص. فقد كنت حريصاً في كل واحدة من هذه الحالات. وزيادة الحرص ليست تقنية يمكن الاعتماد عليها.
اختبر الأداة على شيء تعرف مسبقاً أنه موجود.
هذه هي الطريقة كلها. قبل أن تقبل أن الفحص لم يجد شيئاً، وجّهه إلى حالة تعرف أن نتيجتها يجب أن تكون إيجابية، وتأكد أولاً من أنه يعثر عليها. فإذا كانت الأداة لا تستطيع العثور على شيء تعرف أنه موجود، فهي لا تستطيع أن تخبرك بشيء عن الحالة التي تريد فحصها.
وقد طبقت هذه الفكرة بشكل صحيح مرتين في الأسبوع نفسه، ولهذا أعرف أنها تنفع. فعند التحقق من ملفين شخصيين على منصتين تعيدان الحالة 200 لكل شيء، حتى للصفحات غير الموجودة، اختبرت اسماً وهمياً اخترعته عمداً إلى جانب الاسم الحقيقي:
hashnode.com/@realhandle → <title>Jaafar Abazid (@realhandle) | Hashnode</title>
hashnode.com/@zzqq-not-a-real-user → <title>User not found | Hashnode</title>
كانت رموز الحالة status codes متطابقة. أما العناوين فلم تكن كذلك. والفرق بينهما هو الإشارة التي تبحث عنها، وهي موجودة سواء فكرت في البحث عنها أم لا.
أما في Crunchbase، فلم أجرِ هذا الاختبار المقارن أصلاً، لأن صفحة تحمل عبارة الصفحة غير موجودة بدت لي نهاية التحقيق لا بدايته.
القائمة التي أعمل بها الآن
- قبل أن تستنتج الغياب، أثبت أن الأداة قادرة على اكتشاف الموجود. اختبرها دائماً بحالة واحدة تعرف مسبقاً أنها موجودة.
- إذا كانت النتيجة المجمّعة هي الخيار الأسهل، فتحقق من عنصر واحد عبر نقطة النهاية الخاصة به. فواجهات القوائم وواجهات العناصر المنفردة تسلك مسارات برمجية مختلفة، وقد تنحرف نتائجها عن بعضها.
- اقرأ اسم الحقل كما هو حرفياً. فالحقل
public_reposيحصي المستودعات التي يملكها الحساب، وليس مرادفاً لعبارة “لديه شيء على ملفه الشخصي”. - تحقق من السلوك المخصص للمتصفح باستخدام متصفح، ومن السلوك الذي يتطلب تسجيل الدخول بعد تسجيل الدخول. فقد تعرض المنصة ثلاث حقائق مختلفة لكل من
curl، والمتصفح غير المسجل، والمتصفح المسجل. - بعد أي عملية كتابة، اقرأ النتيجة نفسها، لا ما تعرضه واجهة التحرير. فالواجهة تخبرك بما كانت تنوي فعله، أما النتيجة الفعلية وحدها فتخبرك بما حدث.
- لا تعدّ شيئاً في استجابة لم تتأكد أولاً أنها هي الاستجابة التي طلبتها. تحقق من رمز الحالة status code قبل أن تبدأ بتحليل المحتوى.
الفكرة التي أعود إليها دائماً
إنّ الفحص الذي يفشل ويخبرك بأنه فشل. ثم تعيد المحاولة، وتقرأ رسالة الخطأ، وتصلح الاستدعاء. صحيح أنه مزعج وصريح، ولكنه لا يكلّفك سوى خمس دقائق.
أما الفحص الذي لا يستطيع رؤية الشيء الذي تسأل عنه، فلا يفشل. بل يجيب عن سؤال مجاور، وبالصيغة نفسها، وبالثقة نفسها، ثم يسلّمك نتيجة تنسجم تماماً مع الخطوة التالية التي كنت على وشك القيام بها.
ما يزال رابط Crunchbase موجوداً في البيانات المنظمة structured data على كل صفحة من صفحات هذا الموقع. وقد بقي هناك لأن لقطة شاشة وصلت قبل تنفيذ الـ commit، لا لأن عملية التحقق اكتشفت المشكلة.
ليس من المريح أن تنشر اعترافاً كهذا. لكنه السبب الذي يجعل حالة الاختبار المرجعية Control Case أول ما أكتبه الآن، حتى قبل الفحص نفسه.