الوحدة 5 — معالجة الأخطاء ورموز الحالة
خدمة تنبّؤ في الإنتاج تفشل. النموذج قد يرمي استثناءً على قيمة مُريبة، قاعدة عمل قد تُرفض طلبًا، ملفّ قد يختفي فجأة. الفارق بين خدمة هاوية وخدمة مهنيّة يظهر هنا: هل الأخطاء تُعاد بشكل مُهيكل مع رمز HTTP الصحيح ورسالة مفيدة، أم يستقبل العميل تتبّع استثناء بمئتَي سطر يكشف بنية الخادم؟ هذه الوحدة تُنظّم الأخطاء لتخدم من يستهلك الواجهة، وتحمي الخادم من كشف نفسه.
سُلَّم رموز HTTP التي نحتاجها
خمس فئات نستعملها فعلًا في خدمة تنبّؤ:
- 200 OK: نجاح كامل.
- 400 Bad Request: الطلب مفهوم نحويًّا لكن ّه يخرق قاعدة عمل (customer_id مُغلق).
- 401 Unauthorized / 403 Forbidden: الجيتون مفقود أو غير صالح (الوحدة 7).
- 404 Not Found: مورد غير موجود (نموذج بإصدار غير معروف).
- 422 Unprocessable Entity: الطلب مفهوم نحويًّا لكنّه يخرق مخطّط المدخلات. FastAPI يُعيده تلقائيًّا عند خرق Pydantic.
- 429 Too Many Requests: تجاوز الحدّ الأقصى للمعدّل (الوحدة 7).
- 500 Internal Server Error: خطأ داخليّ غير متوقّع.
- 503 Service Unavailable: النموذج لم يُحمَّل أو تبعيّة خارجيّة معطّلة.
القاعدة العمليّة: لا تعيد 200 مع حقل error داخل جسم JSON. هذا يُربك العملاء ويكسر الوسطاء (proxies، caches) التي تعتمد على رمز HTTP.
HTTPException: الأداة الأساسيّة
FastAPI يوفّر HTTPException لرمي خطأ HTTP بأيّ رمز:
from fastapi import HTTPException, status
@app.post("/predict", response_model=ChurnResponse)
def predire(req: ChurnRequest) -> ChurnResponse:
if req.contract == "two-year" and req.tenure_months == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="عقد سنتَين مع مدّة اشتراك صفر: حالة غير متّسقة",
)
if app.state.model is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="النموذج غير محمَّل بعد",
)
...
detail يظهر في جسم الاستجابة كـ{"detail": "..."}. لأخطاء أعقد، نمرّر قاموسًا كاملًا.
400 مقابل 422: التمييز الجوهريّ
الخلط الأكثر شيوعًا. القاعدة القطعيّة:
- 422 تلقائيّ من Pydantic لخرق مخطّط: نوع خاطئ، حقل مفقود، قيمة خارج النطاق. مثال:
tenure_months: -5يخرقge=0. - 400 يدويّ من الشيفرة لخرق قاعدة عمل لا يُعبَّر عنها بالمخطّط. مثال: عقد سنتَين مع مدّة صفر.
كثيرون يُعيدون 400 لكلّ شيء «لأنّه أشهر». هذا يُلغي معلومة قيّمة للعميل: 422 يعني «طلبك مشوّه، أصلحه في الكود»؛ 400 يعني «طلبك سليم لكن الأعمال ترفضه، أعِد التفكير في المنطق». الفرق حاسم في التصحيح.
استجابة خطأ مُهيكلة
بدل جسم عاديّ، نُصمّم مخطّطًا موحّدًا يتّبعه كلّ خطأ (مستوحى من RFC 7807 «Problem Details»):
class ErreurAPI(BaseModel):
error_code: str # كود ثابت قابل للترجمة
message: str # رسالة موجزة للعميل
request_id: str # معرّف لربط الاستجابة بسجلّات الخادم
field: str | None = None # الحقل المسبّب عند الاقتضاء
ثمّ نستعمل معالج استثناء مركزيّ لتوحيد الشكل:
from fastapi.responses import JSONResponse
from fastapi.requests import Request
import uuid
@app.exception_handler(HTTPException)
async def gerer_http(request: Request, exc: HTTPException) -> JSONResponse:
rid = request.headers.get("x-request-id", str(uuid.uuid4()))
return JSONResponse(
status_code=exc.status_code,
content=ErreurAPI(
error_code=f"HTTP_{exc.status_code}",
message=str(exc.detail),
request_id=rid,
).model_dump(),
)
الآن كلّ استجابة خطأ لها نفس البنية. العميل يكتب معالجًا واحدًا لكلّ الأخطاء بدل استثناء لكلّ رمز.
القاعدة الذهبيّة: لا تُعِد تتبّع الاستثناء
الخطأ الأمنيّ الأكثر شيوعًا في FastAPI هو تسريب traceback عند 500. تتبّع الاستثناء يكشف: مسار الملفّ على الخادم، إصدارات المكتبات، أسماء الحقول الداخليّة، وأحيانًا معلومات سرّيّة (مفاتيح API في متغيّرات بيئة). المهاجم يستعمل هذا لبناء هجومه.
الحلّ: معالج ا ستثناء عامّ يُسجّل التفاصيل في الخادم ويُعيد رسالة عامّة للعميل:
import logging
import traceback
logger = logging.getLogger("app")
@app.exception_handler(Exception)
async def gerer_tout(request: Request, exc: Exception) -> JSONResponse:
rid = request.headers.get("x-request-id", str(uuid.uuid4()))
logger.exception("erreur non geree", extra={
"request_id": rid,
"path": request.url.path,
"traceback": traceback.format_exc(),
})
return JSONResponse(
status_code=500,
content=ErreurAPI(
error_code="INTERNAL_ERROR",
message="حدث خطأ داخليّ. تواصل مع الدعم مع request_id.",
request_id=rid,
).model_dump(),
)
المهمّ: التتبّع يذهب إل ى سجلّ الخادم فقط، والعميل يحصل على رسالة قصيرة مع request_id يستطيع فريق الدعم استعماله للعثور على السجلّ الكامل. لا معلومة تُهدر، ولا معلومة تُسرَّب.
تحويل استثناءات المكتبات
النموذج قد يرمي ValueError عندما تُمرَّر قيمة غريبة (فئة غير معروفة، NaN غير متوقّع). لا نترك هذا يتحوّل إلى 500 عامّ:
@app.post("/predict")
def predire(req: ChurnRequest) -> ChurnResponse:
try:
proba = float(app.state.model.predict_proba(df)[0][1])
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"قيمة غير مقبولة من النموذج: {e}",
)
هذا يُحوّل خطأ صامتًا (500 غامض) إلى خطأ واضح (422 مع سبب). العميل يفهم أنّ عليه إصلاح الطلب.
اختبار أخطاء
الاختبارات يجب أن تُغطّي مسار السعادة ومسارات الفشل:
def test_predict_422_type_errone():
r = client.post("/predict", json={
"tenure_months": "vingt", "monthly_charges": 79.5,
"contract": "month-to-month", "payment_method": "electronic-check",
"fiber_optic": True, "online_security": False,
})
assert r.status_code == 422
assert "tenure_months" in r.text
def test_predict_400_regle_metier():
r = client.post("/predict", json={
"tenure_months": 0, "monthly_charges": 79.5,
"contract": "two-year", "payment_method": "credit-card",
"fiber_optic": True, "online_security": True,
})
assert r.status_code == 400
بدون هذه الاختبارات، إعادة صياغة الشيفرة قد تكسر معالجة الأخطاء بلا أن يلاحظ أحد.
الخلاصة
- 422 لخرق مخطّط (تلقائيّ من Pydantic)، 400 لخرق قاعدة عمل (يدويّ)؛ الخلط بينهما يُلغي معلومة تشخيصيّة قيّمة.
- استجابة خطأ مُهيكلة بمخطّط موحّد (error_code، message، request_id) تُبسّط استهلاك الواجهة.
- لا تُعِد تتبّع الاستثناء أبدًا: التتبّع يذهب إلى سجلّ الخادم، والعميل يحصل على رسالة عامّة مع معرّف طلب.
- استثناءات المكتبات يجب أن تُحوَّل إلى
HTTPExceptionبالرمز المناسب، لا أن تتحوّل إلى 500 غامض.