حين يُرفَض رفع ملف PDF كبير: حدّ جسم الطلب البالغ 100 MiB والخطأ الذي يضلّل مسار التشخيص
قد يبلغ جسم الطلب الواحد 100 MiB، أي 104,857,600 بايت، بما في ذلك تأطير multipart؛ وفوق الحد ترد نقاط نهاية العمليات بالرمز 400 مع code قيمته bad_request وتفصيل عن حقل multipart فاشل، لا 413، فيُقرأ مشكل الحجم كمشكل معاملات؛ ومع Idempotency-Key يوجد حد ثانٍ قدره 100 MiB على مخزن الردود المؤقت، وفوقه تحصل على 500 دون تخزين أي شيء.

ملف PDF كبير لا يُرفع ليس في الغالب ملفاً تالفاً. بل بلغ جسم الطلب الحدّ المفروض على الطلب الواحد: 100 MiB، أي 104,857,600 بايت. ويُقاس على الجسم كله، بما فيه حدود multipart وترويسات الحقول، وهذا الرقم بالضبط يمرّ بينما تُرفض زيادة بايت واحد.
الخطأ العائد يشير إلى موضع آخر. ترد نقاط نهاية العمليات بالرمز 400 مع code قيمته bad_request وتفصيل يقول إن حقل multipart تعذّرت قراءته، دون أي ذكر للحجم. والعميل الذي يتفرّع على code يسجّل هذا كخطأ في المعاملات ويمضي لفحص أسماء الحقول، بينما الذي يجب تغييره هو حجم الملف.
والحدّ نفسه البالغ 100 MiB يحكم الاتجاه المعاكس أيضاً. فالطلب الذي يحمل Idempotency-Key يقرأ الرد في الذاكرة قبل تخزينه، ووفق الرقم نفسه، وفوق الحد يتلقى المستدعي الرمز 500 بينما تكون العملية قد انتهت فعلاً. رقم واحد، وطريقتا فشل متعاكستان.
100 MiB = 104,857,600 بايت (هذا الرقم بالضبط يمرّ)
|
+------------------+------------------+
| |
الوارد (جسم الطلب) الصادر (جسم الرد)
الجسم كله، بما فيه حدود multipart فقط مع Idempotency-Key،
وترويسات الحقول؛ عملية واحدة وغياب الإصابة في الذاكرة
وخط معالجة يتقاسمانه المؤقتة، واستجابة 2xx من المنبع
فوق الحد: 400 + bad_request فوق الحد: 500، ولا شيء يُخزَّن
(التفصيل: تعذّرت قراءة حقل multipart)
ملاحظة الشكل: حد واحد، وطرفاه الوارد والصادر يعيدان رمزي حالة مختلفين ونتيجتين متعاكستين.
الحدّ يحسب جسم الطلب كله، بما فيه الحدود الفاصلة
يحدّ 100 MiB جسم الطلب الواحد، لا حجم ملف واحد ولا الحجم بعد فك الضغط.
- تتقاسم عملية واحدة وخط معالجة متعدد الخطوات الرقم نفسه. وتقسيم العمل إلى 10 خطوات داخل استدعاء واحد لـ
/api/v1/pipelineلا يحوّل السقف إلى 1 GB؛ فعدد الخطوات يؤثر في زمن التنفيذ فقط. - قيمة الحدّ نفسها تمرّ: جسم طلب من 104,857,600 بايت يمرّ، وجسم من 104,857,601 بايت لا يمرّ.
- يحمل الجسم ترويسات كل حقل والفواصل الحدّية إلى جانب بايتات الملف، لذا تكون الحصة المتبقية لملف واحد أقل من 100 MiB قطعاً. وملف من 104,857,600 بايت بالضبط يُرفض.
وهذه النقطة الأخيرة هي أكثر ما تزلّ فيه الممارسة: فـ curl -F يضيف الفواصل نيابة عنك، لذا لا يمكن أن تصحّ مقارنة حجم ملف بذلك الخط.
فوق الحد تحصل على 400 وbad_request
الرد الذي يتجاوز الحد لا يستخدم 413، ولا يحمل أي نص عن الحجم. أعد إنتاجه بجسم يزيد بايتاً واحداً:
head -c 104857601 /dev/zero > /tmp/over.bin
curl -s -X POST "$API_BASE/api/v1/misc/compress-pdf" \
-H "X-API-KEY: $API_KEY" \
-F "fileInput=@/tmp/over.bin"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
{ "code": "bad_request",
"detail": "failed to read multipart field: Error parsing `multipart/form-data` request",
"hint": "Fix request parameters or upload a valid PDF.",
"status": 400,
"title": "Bad Request",
"type": "https://pdf123.xyz/developers/errors#bad_request" }
ثلاثة أمور تُقرأ معاً:
- الحالة 400. فقراءة الجسم الفاشلة تُصنَّف هنا
bad_requestوتمر مع ذلك عبر problem+json، لذا يتخذ العميل المكتوب على قاعدة «تجاوز الحد يعني 413» فرعاً خاطئاً. - قيمة
codeهيbad_request، وهي مدخل حقيقي في جدول رموز الخطأ. ولا تسقط إلى فرع عام، بل تتقاسم رمزاً واحداً مع اسم حقل مكتوب خطأً أو ترميز multipart معطوب، ولا شيء في ذلك الجدول يتعلق بالحجم. - تقول
hintإن ترفع ملف PDF صالحاً. والملف الذي رفعته غالباً ملف PDF صالح، لكنه أكبر ببضع مئات من البايتات.
فالدليل إذن ليس رمز الحالة بل عدد بايتات الجسم: عند رد 400 يحوي detail فيه failed to read multipart field، قِس الحجم في الخطوة التالية بدل العودة لتفقّد النموذج.
يظهر 413 في هذا الموقع أيضاً، لكن ليس على نقاط نهاية العمليات. فقد رُفع كل مدخل يقبل رفع ملف إلى 100 MiB، أما نقاط النهاية التي لا تستقبل رفعاً فما زالت تعمل على الحد الافتراضي لإطار HTTP (وهو axum في Rust) البالغ 2 MiB، وفيه يحصل جسم متجاوز على 413 وسطر واحد من نص عادي:
head -c 2097153 /dev/zero > /tmp/big.json
curl -s -w '\n%{http_code}\n' -X POST "$API_BASE/api/v1/auth/login" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/big.json
Failed to buffer the request body: length limit exceeded
413
حقيقة واحدة، ورمزان للحالة وجسمان للرد بحسب نقطة النهاية. ونقل أي من التجربتين إلى الأخرى سيضلّلك.
الفشل الآخر بالرقم نفسه، في طريق العودة
الطلب الذي يحمل Idempotency-Key يخزّن رده في الذاكرة المؤقتة كي تستطيع إعادة المحاولة تشغيله ثانية. والتخزين يعني قراءة جسم الرد في الذاكرة أولاً، وتلك القراءة محدودة بـ 100 MiB نفسها، لكنها لا تقع إلا بمجموعة شروط أضيق بكثير:
- أن يكون الطلب قد حمل
Idempotency-Key - أن تغيب الإصابة في الذاكرة المؤقتة، أي أن هذا المفتاح جديد
- أن تعيد العملية في المنبع استجابة 2xx
الفشل لا يُخزَّن ويمر مباشرة، لذا لا يبلغ هذا الحد إلا خرج كبير ناجح.
وما يحدث هناك هو النقطة الجديرة بالتذكر: تحصل على 500، ولا يُكتب شيء في الذاكرة المؤقتة. فقد نُفّذت العملية فعلاً، ومع ذلك يرى المستدعي فشلاً؛ ولأن شيئاً لم يُخزَّن، تعيد المحاولة تشغيل الأمر كله من جديد. ومفتاح idempotency موجود لإزالة العمل المكرر، لكنه يفشل في اللحظة التي يُحتاج إليه فيها أكثر من غيرها، فيزيّن مهمة منتهية بمظهر عطل في الخادم. والذاكرة المؤقتة تعيش في ذاكرة العملية ولا تُكتب على القرص أبداً، لذا تمسحها إعادة التشغيل؛ وللتفاصيل الدلالية راجع Idempotency-Key: إعادات محاولة آمنة لمهام PDF.
الحد 100 MiB ليس إعداداً قابلاً للضبط في المنصة
لا يمكن تغيير الرقم. فلا متغيّر بيئة يرفعه أو يخفضه، في أي من الاتجاهين؛ ورقم مختلف يعني تغيير الشيفرة وإعادة بناء الصورة. وستبحث عنه كإعداد نشر فلا تجد شيئاً.
وهو أيضاً أكثر من حصة. فبايتات جسم الطلب تُقرأ كاملة في الذاكرة قبل المعالجة، لذا يحمل كل رفع كبير متزامن بجانبه كمية مماثلة. ورفع السقف يعني قبول ذروة ذاكرة أعلى معه: وهذا الرقم أيضاً هو ما يمنع طلباً واحداً من جرّ العملية إلى الأسفل.
في نشر افتراضي مستضاف ذاتياً لا يوجد وكيل عكسي، ولا تتحقق البوابة من الحجم قبل الإرسال، لذا يأتي ذلك الرفض من حدّ الخادم نفسه. وإن وضعت nginx أمامه فستصطدم بـ nginx أولاً: فـ client_max_body_size لا يسمح افتراضياً إلا بـ 1 MiB ويرد بالرمز 413، وهو شكل قريب من الشكل في المقارنة أعلاه ويسهل الخطأ بحسبه على الحد نفسه.
ما تفعله عند الاصطدام به
الأرخص أولاً:
- قِس قبل أن ترسل. قارن عدد بايتات جسم الطلب بـ 104,857,600 قبل خروج الطلب، واترك هامشاً للفواصل الحدّية. وهذا خير من قراءة رمز حالة بعد ذلك.
- اضغط الملف إلى ما دون الحد. يأتي حجم المسح في الغالب من طبقة صوره، وإعادة الضغط تزيل عادة جزءاً ظاهراً منه. وضغط PDF يعمل في المتصفح بلا حاجة إلى سكربت.
- قسّم العمل على عدة استدعاءات. حين يقبل المحتوى التقسيم، قسّمه وأرسله في بضعة طلبات: أقل عناءً من رفع السقف، ولا يزيد الذاكرة التي يحملها طلب واحد.
- لا تغيّر الحد إلا لحاجة صارمة إلى طلب واحد. فذلك يعني تغيير الشيفرة وإعادة البناء، وقبول كلفة الذاكرة الواردة في القسم السابق.
هذا الحد يطلب من العميل أن يقرر مسبقاً
يشير الفشلان معاً إلى نتيجة واحدة: على العميل أن يحسب رقم 100 MiB قبل أن يرسل. في الاتجاه الوارد يُجاب بـ bad_request، وهو رمز لا يقول شيئاً عن الحجم؛ وفي الاتجاه الصادر يُجاب بـ 500، الذي يبدو عطلاً في الخادم. وعدّ البايتات قبل خروج الطلب هو السبيل الوحيد للحكم الذي لا يعتمد على ما تقوله رسالة خطأ.