نُهج معالجة أخطاء Go API التي توحّد الأخطاء المصنفة، رموز حالة HTTP، معرفات الطلب، ورسائل آمنة دون تسريب التفاصيل الداخلية.

عندما يقدم كل مسار أخطاء بطريقة مختلفة، يتوقف العملاء عن الوثوق في الـ API. إحدى المسارات تعيد { "error": "not found" }، وأخرى تعيد { "message": "missing" }، وثالثة ترسل نصًا عاديًا. حتى لو كان المعنى قريبًا، يصبح على كود العميل الآن أن يخمن ما حدث.
التكلفة تظهر بسرعة. يبني الفرق منطق تحليل هش ويضيف حالات خاصة لكل مسار. تصبح عمليات إعادة المحاولة محفوفة بالمخاطر لأن العميل لا يستطيع التمييز بين "حاول مرة أخرى لاحقًا" و"دخلك خاطئ". تزداد تذاكر الدعم لأن العميل يرى رسالة غامضة، ولا يستطيع فريقك بسهولة مطابقة ذلك مع سطر سجل على الخادم.
سيناريو شائع: تطبيق جوال يستدعي ثلاثة مسارات أثناء التسجيل. الأول يعيد HTTP 400 مع خريطة أخطاء على مستوى الحقل، الثاني يعيد HTTP 500 مع نص تتبع الاستدعاءات، والثالث يعيد HTTP 200 مع { "ok": false }. فريق التطبيق يطلق ثلاثة معالجات أخطاء مختلفة، وفريق الباكاند ما زال يحصل على تقارير مثل "التسجيل يفشل أحيانًا" بدون دليل واضح من أين نبدأ.
الهدف هو عقد واحد قابل للتوقع. يجب أن يستطيع العملاء قراءة ما حدث بشكل موثوق: هل الخطأ من طرفهم أم من طرفكم؟ هل من المنطقي إعادة المحاولة؟ وهل يوجد معرف طلب يمكن لصقه في تقرير دعم؟
ملاحظة النطاق: يركز هذا على واجهات JSON عبر HTTP (ليس gRPC)، لكن نفس الأفكار تنطبق في أي مكان تُعيد فيه أخطاء إلى نظم أخرى.
اختر عقدًا واضحًا للأخطاء واجعل كل مسار يلتزمه. "متسق" يعني نفس شكل JSON، ونفس معنى الحقول، ونفس السلوك بغض النظر عن المعالج الذي فشل. بمجرد فعل ذلك، يتوقف العملاء عن التخمين ويبدؤون بمعالجة الأخطاء.
عقد مفيد يساعد العملاء على اتخاذ قرار ما الذي يفعلونه بعد ذلك. لمعظم التطبيقات، يجب أن تجيب كل استجابة خطأ على ثلاثة أسئلة:
مجموعة قواعد عملية:
قرر مسبقًا ما يجب ألا يظهر أبدًا في الاستجابات. العناصر الشائعة التي يجب ألا تظهر تشمل مقتطفات SQL، تتبعات الاستدعاءات، أسماء المضيفين الداخلية، أسرار، وسلاسل الأخطاء الخام من التبعيات.
حافظ على تقسيم نظيف: رسالة قصيرة موجهة للمستخدم (آمنة، مهذبة، قابلة للتنفيذ) وتفاصيل داخلية (الخطأ الكامل، التتبع، والسياق) تُحفظ في السجلات. على سبيل المثال، "تعذر حفظ التغييرات. يرجى المحاولة لاحقًا." آمنة. بينما "pq: duplicate key value violates unique constraint users_email_key" ليست آمنة.
عندما يلتزم كل مسار بنفس العقد، يستطيع العميل بناء معالج أخطاء واحد وإعادة استخدامه في كل مكان.
يستطيع العملاء التعامل مع الأخطاء بشكل نظيف فقط إذا أجاب كل مسار بنفس الشكل. اختر مغلف JSON واحد وحافظ عليه ثابتًا.
افتراضي عملي مفيد هو وجود كائن error بالإضافة إلى request_id على المستوى الأعلى:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
تعطي حالة HTTP الفئة العامة (400، 401، 409، 500). يعطي error.code المقروء آليًا الحالة المحددة التي يمكن للعميل التفريع عليها. هذا الفصل مهم لأن العديد من المشاكل المختلفة تشترك بنفس الحالة. قد يعرض تطبيق جوال واجهة مختلفة لحالة EMAIL_TAKEN مقابل WEAK_PASSWORD، حتى لو كان كلاهما 400.
حافظ على error.message آمنة وبشرية. يجب أن تساعد المستخدم في إصلاح المشكلة، لكن لا تكشف التفاصيل الداخلية (SQL، تتبعات الاستدعاءات، أسماء مقدمي الخدمة، مسارات الملفات).
الحقول الاختيارية مفيدة عندما تبقى متوقعة:
details.fields كخريطة من الحقل إلى الرسالة.details.retry_after_seconds.details.docs_hint كنص عادي (ليس رابطًا).للتوافق الخلفي، اعتبر قيم error.code جزءًا من عقد الـ API الخاص بك. أضف رموزًا جديدة دون تغيير المعاني القديمة. أضف حقولًا اختيارية فقط، وافترض أن العملاء سيتجاهلون الحقول التي لا يتعرفون عليها.
تصبح معالجة الأخطاء فوضوية عندما يخترع كل معالج طريقته الخاصة للإشارة إلى الفشل. مجموعة صغيرة من الأخطاء المصنفة تحل ذلك: تُعيد المعالجات أنواع خطأ معروفة، وتحوّل طبقة استجابة واحدة هذه الأخطاء إلى استجابات متسقة.
مجموعة بداية عملية تغطي معظم المسارات:
المفتاح هو الثبات على مستوى العقد، حتى لو تغير السبب الجذري. يمكنك تغليف الأخطاء منخفضة المستوى (SQL، شبكة، تحليل JSON) مع الاستمرار في إعادة نفس النوع العام الذي يمكن للـ middleware اكتشافه.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
في معالجك، أعد NotFoundError{Resource: "user", ID: id, Err: err} بدلًا من تسريب sql.ErrNoRows مباشرة.
للفحص، فضّل errors.As لأنواع مخصصة وerrors.Is للأخطاء المعروفة (sentinel). تعمل الأخطاء المعروفة لحالات بسيطة، لكن الأنواع المخصصة تفوز عندما تحتاج سياقًا آمنًا (مثل أي مورد كان مفقودًا) دون تغيير عقد استجابتك العامة.
كن صارمًا بشأن ما ترفقه:
Err المضمن، معلومات التتبع، أخطاء SQL الخام، الرموز، بيانات المستخدم.هذا الانقسام يتيح مساعدتك للعملاء دون كشف التفاصيل الداخلية.
بمجرد أن تحصل على أخطاء مصنفة، المهمة التالية مملة لكنها أساسية: يجب أن ينتج عن نفس نوع الخطأ نفس حالة HTTP دائمًا. سيبني العملاء منطقًا حول ذلك.
تطابق عملي يعمل لمعظم الـ APIs:
| Error type (example) | Status | When to use it |
|---|---|---|
| BadRequest (malformed JSON, missing required query param) | 400 | The request is not valid at a basic protocol or format level. |
| Unauthenticated (no/invalid token) | 401 | The client needs to authenticate. |
| Forbidden (no permission) | 403 | Auth is valid, but access is not allowed. |
| NotFound (resource ID does not exist) | 404 | The requested resource is not there (or you choose to hide existence). |
| Conflict (unique constraint, version mismatch) | 409 | The request is well-formed, but it clashes with current state. |
| ValidationFailed (field rules) | 422 | The shape is fine, but business validation fails (email format, min length). |
| RateLimited | 429 | Too many requests in a time window. |
| Internal (unknown error) | 500 | Bug or unexpected failure. |
| Unavailable (dependency down, timeout, maintenance) | 503 | Temporary server-side issue. |
تمييزان يمنعان الكثير من الالتباس:
إرشادات إعادة المحاولة مهمة:
معرف الطلب هو قيمة قصيرة فريدة تُحدد نداء API واحدًا من البداية إلى النهاية. إذا استطاع العملاء رؤيته في كل استجابة، يصبح الدعم بسيطًا: "أرسل لي معرف الطلب" يكفي في كثير من الأحيان للعثور على السطر الدقيق في السجلات والفشل الدقيق.
تعود هذه العادة بالفائدة على كل من الاستجابات الناجحة والخاطئة.
استخدم قاعدة واضحة واحدة: إذا أرسل العميل معرف طلب، احتفظ به. إذا لم يفعل، أنشئ واحدًا.
X-Request-Id).ضع معرف الطلب في ثلاثة أماكن:
request_id في مخططك القياسي)بالنسبة لنقاط النهاية التي تُعالج دفعات أو وظائف خلفية، احتفظ بمعرف طلب أبوي. مثال: يرفع العميل 200 صفًا، 12 تفشل في التحقق، وتُدرج الأعمال في قائمة الانتظار. أعد request_id واحدًا للمكالمة كاملة، وأضف parent_request_id على كل مهمة وكل خطأ على مستوى العنصر. بهذه الطريقة، تستطيع تتبع "رفع واحد" حتى لو تفجر إلى مهام عديدة.
يحتاج العملاء إلى استجابة خطأ واضحة وثابتة. تحتاج سجلاتك إلى الحقيقة الفوضوية. حافظ على هذين العالمين منفصلين: أعد رسالة آمنة ورمز خطأ عام للعميل، مع تسجيل السبب الفعلي، والتتبع، والسياق على الخادم.
سجل حدثًا منظمًا واحدًا لكل استجابة خطأ، يمكن البحث عنها بواسطة request_id.
حقول تستحق الاتساق:
خزن التفاصيل الداخلية فقط في سجلات الخادم (أو مستودع أخطاء داخلي). لا يجب أن يرى العميل أبدًا أخطاء قاعدة بيانات خام، نصوص الاستعلام، تتبعات الاستدعاءات، أو رسائل مقدمي الخدمة. إذا كان لديك خدمات متعددة، فإن حقلًا داخليًا مثل source (api, db, auth, upstream) يمكن أن يسرع التحقيق.
راقب النقاط النهائية المزعجة وأخطاء الحدود. إذا كان مسار ما يمكن أن ينتج نفس 429 أو 400 آلاف مرة في الدقيقة، تجنب إغراق السجلات: عيّن عينة للأحداث المتكررة، أو خفف الشدة للأخطاء المتوقعة مع استمرار عدها في القياسات.
القياسات تكتشف المشاكل أبكر من السجلات. تتبع العدادات مجمعة حسب حالة HTTP ورمز الخطأ، ونبه على القفزات المفاجئة. إذا قفز RATE_LIMITED عشرة أضعاف بعد نشر نُسخة، سترى ذلك بسرعة حتى لو كانت السجلات مأخوذة بعين الاعتبار.
أسهل طريقة لجعل الأخطاء متسقة هي التوقف عن التعامل معها "في كل مكان" وتوجيهها عبر خط أنابيب صغير واحد. يقرر هذا الخط ما يراه العميل وما تُبقيه للسجلات.
ابدأ بمجموعة صغيرة من رموز الأخطاء التي يمكن للعملاء الاعتماد عليها (مثل: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). غلّفها في خطأ مصنف يعرِّض فقط الحقول الآمنة العامة (code, safe message, تفاصيل اختيارية مثل أي حقل خاطئ). احتفظ بالأسباب الداخلية خاصة.
ثم نفّذ دالة مترجمة واحدة تحول أي خطأ إلى (statusCode, responseBody). هنا يتم تطابق الأخطاء المصنفة إلى حالات HTTP، وتتحول الأخطاء المجهولة إلى استجابة 500 آمنة.
بعدها، أضف middleware الذي:
request_id لكل طلبلا يجب أبدًا أن يفرغ panic تتبعات الاستدعاءات إلى العميل. أعد استجابة 500 عادية برسالة عامة، وسجل panic الكامل بنفس request_id.
أخيرًا، غيّر معالجاتك لتعيد error بدلًا من كتابة الاستجابة مباشرة. يمكن لطبقة واحدة أن تستدعي المعالج، تشغّل المترجم، وتكتب JSON بالشكل القياسي.
قائمة تدقيق مختصرة:
تهمّ الاختبارات الذهبية لأنها تثبت العقد. إذا غيّر أحدهم رسالة أو رمز حالة لاحقًا، تفشل الاختبارات قبل أن يتفاجأ العملاء.
تخيل مسارًا واحدًا: تطبيق ينشئ سجل عميل.
POST /v1/customers مع JSON مثل { "email": "[email protected]", "name": "Pat" }. الخادم يعيد دائمًا نفس شكل الخطأ ويضم دائمًا request_id.
البريد الإلكتروني مفقود أو غير صحيح. يمكن للعميل تمييز الحقل.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
البريد الإلكتروني موجود بالفعل. يمكن للعميل اقتراح تسجيل الدخول أو اختيار بريد آخر.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
إحدى التبعيات معطلة. يمكن للعميل إعادة المحاولة مع تراجع زمني وإظهار رسالة هادئة.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
مع عقد واحد، يتصرف العميل باستمرار:
details.fieldsrequest_id كمعرف دعمللدعم، يكون نفس request_id أسرع مسار للوصول إلى السبب الحقيقي في السجلات الداخلية، دون كشف تتبعات الاستدعاءات أو أخطاء قاعدة البيانات.
أسرع طريقة لإزعاج عملاء API هي إجبارهم على التخمين. إذا أعاد مسار { "error": "..." } وآخر { "message": "..." }، يتحول كل عميل إلى كومة من الحالات الخاصة، وتختبئ الأخطاء لأسابيع.
بعض الأخطاء تتكرر مرارًا:
request_id فقط عند الفشل، فلا يمكنك ربط تقرير مستخدم مع النداء الناجح الذي قد يكون تسبب في المشكلة لاحقًا.تسريب التفاصيل الداخلية هو أسهل مصيدة تقع فيها. يعيد معالج err.Error() لأن ذلك مريح، ثم ينتهي الأمر باسم قيد أو رسالة طرف ثالث في ردود الإنتاج. اجعل رسالة العميل قصيرة وآمنة، وضع السبب المفصّل في السجلات.
الاعتماد على النص وحده خطأ بطيء. إذا اضطر العميل لتحليل جمل إنجليزية مثل "email already exists"، لا يمكنك تغيير الصياغة دون كسر المنطق. تسمح رموز الأخطاء الثابتة بتعديل الرسائل، ترجمتها، والحفاظ على سلوك ثابت.
عامل رموز الأخطاء كجزء من عقد الـ API العام. إذا اضطررت لتغيير أحدها، أضف رمزًا جديدًا واحتفظ بالقديم يعمل لفترة، حتى لو كان كلاهما يشيران إلى نفس حالة HTTP.
أخيرًا، أدرج نفس حقل request_id في كل استجابة، نجاحًا أو فشلًا. عندما يقول المستخدم "نجح ثم فشل"، غالبًا ما يوفر ذلك المعرف ساعة من التخمين.
قبل الإصدار، قم بمراجعة سريعة للاتساق:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). أضف اختبارات حتى لا تعيد المعالجات رموزًا مجهولة عن طريق الخطأ.request_id وسجّله لكل طلب، بما في ذلك panics والمهلات.بعد ذلك، افحص يدويًا بعض المسارات. استدع خطأ تحقق، سجل مورد مفقود، وفشل غير متوقع. إذا اختلفت الاستجابات بين المسارات (تغيّر الحقول، انحرفت رموز الحالة، تشارك الرسائل تفاصيل داخلية)، أصلح خط الأنابيب المشترك قبل إضافة المزيد من الميزات.
قاعدة عملية: إذا كانت الرسالة ستساعد مهاجمًا أو تُربك مستخدمًا عاديًا، فهي تنتمي إلى السجلات، لا إلى الاستجابة.
دوّن عقد الخطأ الذي تريد أن يتبعه كل مسار، حتى لو كان الـ API قائمًا بالفعل. عقد مشترك (الحالة، رمز خطأ ثابت، رسالة آمنة، وrequest_id) هو أسرع طريق لجعل الأخطاء قابلة للتوقع لدى العملاء.
بعد ذلك، هاجِر تدريجيًا. احتفظ بمعالجاتك الحالية، لكن وجّه إخفاقاتها عبر موّجه واحد يحول الأخطاء الداخلية إلى شكل الاستجابة العام. هذا يحسّن الاتساق دون إعادة كتابة مخاطرية كبيرة، ويمنع المسارات الجديدة من اختراع صيغ جديدة.
احفظ كتالوج صغير لرموز الأخطاء واعتبره جزءًا من الـ API. عندما يريد أحدهم إضافة رمز جديد، قم بمراجعة سريعة: هل هو جديد فعلاً؟ هل اسمه واضح؟ وهل يطابق حالة HTTP الصحيحة؟
أضف بعض الاختبارات التي تكتشف الانحراف:
request_id.error.code موجود ويأتي من الكتالوج.error.message آمنة ولا تتضمن تفاصيل داخلية.إذا كنت تبني باكاند Go من الصفر، يمكن أن يساعد تثبيت العقد مبكرًا. على سبيل المثال، Koder.ai (koder.ai) يتضمن وضع تخطيط حيث يمكنك تحديد قواعد مثل مخطط الأخطاء وكتالوج الرموز مقدمًا، ثم الحفاظ على توافق المعالجات مع نمو الـ API.
Use one JSON shape for every error response, across every endpoint. A practical default is a top-level request_id plus an error object with code, message, and optional details so clients can reliably parse and react.
Return error.message as a short, user-safe sentence and keep the real cause in server logs. Don’t return raw database errors, stack traces, internal hostnames, or dependency messages, even if it feels helpful during development.
Use a stable error.code for machine logic and let the HTTP status describe the broad category. Clients should branch on error.code (like ALREADY_EXISTS) and treat the status as guidance (like 409 meaning a state conflict).
Use 400 when the request can’t be reliably parsed or interpreted (malformed JSON, wrong types). Use 422 when the request is well-formed but fails business rules (invalid email format, password too short).
Use 409 when the input is valid but can’t be applied because it conflicts with current state (email already taken, version mismatch). Use 422 for field-level validation where changing the value fixes it without needing a different server state.
Create a small set of typed errors (validation, not found, conflict, unauthorized, internal) and have handlers return them. Then use one shared translator to map those types to status codes and the standard JSON response shape.
Always return a request_id in every response, success or failure, and log it on every server log line. If a client reports an issue, that one ID should be enough to find the exact failure path in logs.
Return 200 only when the operation succeeded, and use 4xx/5xx for errors. Hiding errors behind 200 forces clients to parse body fields and creates inconsistent behavior across endpoints.
Default to no retry for 400, 401, 403, 404, 409, and 422 because retries won’t help without changes. Allow retry for 503, and sometimes 429 after waiting; if you support idempotency keys, retries become safer for POST on transient failures.
Lock the contract with a few “golden” tests that assert status, error.code, and presence of request_id. Add new error codes without changing old meanings, and only add optional fields so older clients keep working.