مهلات سياق Go تمنع تراكم استدعاءات قاعدة بيانات البطيئة والطلبات الخارجية. تعلّم نشر المهل، الإلغاء، والإعدادات الافتراضية الآمنة.

طلب واحد بطيء نادراً ما يكون "مجرد بطيء." بينما ينتظر، يبقي goroutine حياً، يحتفظ بالذاكرة للحزم وكائنات الاستجابة، وغالباً ما يشغل اتصال قاعدة بيانات أو موضعًا في التجمع. عندما تتراكم طلبات بطيئة كافية، تتوقف واجهة API عن أداء العمل المفيد لأن مواردها المحدودة عالقة في الانتظار.
عادةً ما تشعر بذلك في ثلاثة أماكن. تتزايد Goroutines ويزداد حمل الجدولة، فتزداد الكمونية للجميع. تنفد اتصالات تجمع قاعدة البيانات، فيبدأ حتى الاستعلامات السريعة بالانتظار خلف البطيئة. ترتفع الذاكرة بسبب البيانات الجارية والاستجابات الجزئية، مما يزيد عبء GC.
إضافة المزيد من الخوادم غالبًا لا يحل المشكلة. إذا كانت كل نسخة تصطدم بنفس عنق الزجاجة (تجمع DB صغير، خدمة صاعدة بطيئة، حدود معدل مشتركة)، فأنت ببساطة تنقل قائمة الانتظار وتدفع أكثر بينما ما تزال الأخطاء ترتفع.
تخيل معالجًا يقوم بعمل متفرّع: يحمّل مستخدماً من PostgreSQL، يستدعي خدمة مدفوعات، ثم خدمة توصيات. إذا توقف استدعاء التوصيات وتعطّل ولم يُلغَ، فلن ينتهي الطلب أبداً. قد يُعاد اتصال قاعدة البيانات إلى التجمع، لكن تبقى الـ goroutine وموارد عميل HTTP مرتبطة. اضرب ذلك في مئات الطلبات وستحصل على انهيار بطيء.
الهدف بسيط: ضع حدًا زمنيًا واضحًا، أوقف العمل عندما ينقضي الوقت، حرّر الموارد، وأعد خطأ قابلًا للتنبؤ. تعطي مهلات سياق Go لكل خطوة موعدًا نهائيًا حتى يتوقف العمل عندما لا يعد المستخدم في انتظار النتيجة.
context.Context هو كائن صغير تمرره عبر سلسلة الاستدعاءات حتى تتفق كل الطبقات على أمر واحد: متى يجب إيقاف هذا الطلب. تعد المهلات الطريقة الشائعة لمنع تبعية بطيئة واحدة من ربط خادمك.
يمكن أن يحمل السياق ثلاثة أنواع من المعلومات: موعد نهائي (متى يجب التوقف)، إشارة إلغاء (شخص ما قرر الإيقاف مبكرًا)، وبعض القيم المخصصة للطلب (استخدمها باعتدال، ولا تستخدمها للبيانات الكبيرة).
الإلغاء ليس سحريًا. يعرض السياق قناة Done(). عندما تُغلق، يُلغى الطلب أو انتهت مهلته. الشيفرة التي تحترم السياق تتحقق من Done() (غالبًا باستخدام select) وتعود مبكرًا. يمكنك أيضًا فحص ctx.Err() لمعرفة سبب الانتهاء، عادةً context.Canceled أو context.DeadlineExceeded.
استخدم context.WithTimeout لـ "التوقف بعد X ثانية." استخدم context.WithDeadline عندما تعرف بالفعل وقت القطع بالضبط. استخدم context.WithCancel عندما يجب أن يوقِف شرط والد العمل مبكرًا (انقطع العميل، انتقل المستخدم بعيدًا، لديك الإجابة بالفعل).
عندما يُلغى السياق، السلوك الصحيح ممل لكنه مهم: أوقف العمل، أوقف الانتظار على الإدخال/الإخراج البطيء، وأعد خطأً واضحًا. إذا كان المعالج ينتظر استعلام قاعدة بيانات وانتهى السياق، ارجع بسرعة ودع استدعاء قاعدة البيانات يتوقف إذا كان يدعم السياق.
أأمن مكان لإيقاف الطلبات البطيئة هو الحد حيث تدخل الحركة إلى خدمتك. إذا كان الطلب سينتهي بمهلة، فأنت تريد أن يحدث ذلك بتوقع وبشكل مبكّر، لا بعد أن يربط goroutines واتصالات قاعدة البيانات والذاكرة.
ابدأ من الحافة (موازن التحميل، بوابة API، البروكسي العكسي) وضع حدًا صارمًا لمدى عمر أي طلب. هذا يحمي خدمتك المكتوبة بـ Go حتى لو نسي معالج ضبط مهلة.
داخل خادم Go، عيّن مهلات HTTP حتى لا ينتظر الخادم إلى الأبد عميلًا بطيئًا أو استجابة متوقفة. على الأقل، اضبط مهلات لقراءة الرؤوس، قراءة جسم الطلب بالكامل، كتابة الاستجابة، والحفاظ على الاتصالات الخاملة.
اختر ميزانية طلب افتراضية تناسب منتجك. بالنسبة للعديد من واجهات API، 1 إلى 3 ثوانٍ نقطة انطلاق معقولة للطلبات العادية، مع حد أعلى للعمليات المعروفة بالبطيئة مثل التصدير. الرقم الدقيق أقل أهمية من الاتساق والقياس وقاعدة واضحة للاستثناءات.
تتطلب الاستجابات المتدفقة عناية إضافية. من السهل إنشاء تدفق لانهائي بطريق الخطأ حيث يبقي الخادم الاتصال مفتوحًا ويكتب قطعًا صغيرة إلى الأبد، أو ينتظر إلى الأبد قبل البايت الأول. قرر مسبقًا ما إذا كانت النهاية فعلاً تدفقًا. إذا لم تكن كذلك، فطبق حدًا أقصى للوقت الكلي وحدًا أقصى للزمن إلى البايت الأول.
بمجرد أن يكون للحدود موعد نهائي واضح، يصبح من الأسهل بكثير نشر ذلك الموعد عبر كامل الطلب.
أبسط مكان للبدء هو معالج HTTP. حيث يدخل طلب واحد نظامك، لذا فهو مكان طبيعي لوضع حد صارم.
أنشئ سياقًا جديدًا مع موعد نهائي، وتأكد من استدعاء cancel. ثم مرّر ذلك السياق إلى أي شيء قد يحجب: عمل قاعدة البيانات، استدعاءات HTTP، أو حسابات بطيئة.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
قاعدة جيدة: إذا كانت الدالة قد تنتظر الإدخال/الإخراج، فيجب أن تقبل context.Context. احفظ المعالجات قابلة للقراءة بدفع التفاصيل إلى دوال مساعدة صغيرة مثل loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
إذا انقضت المهلة (أو انقطع العميل)، أوقف العمل وارجع برد يناسب المستخدم. تحويل شائع هو context.DeadlineExceeded إلى 504 Gateway Timeout، وcontext.Canceled إلى "العميل اختفى" (غالبًا دون جسم استجابة).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
هذا النمط يمنع التراكم. عندما ينقضي المؤقت، تحصل كل دالة تراعي السياق في السلسلة على نفس إشارة الإيقاف ويمكنها الخروج بسرعة.
بمجرد أن يكون للمعالج سياق مع موعد نهائي، القاعدة الأهم بسيطة: استخدم نفس ctx طوال الطريق إلى استدعاء قاعدة البيانات. هكذا توقف المهلات العمل بدل أن توقف المعالج فقط عن الانتظار.
مع database/sql، فضّل الطرق الداعمة للسياق:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
إذا كانت ميزانية المعالج 2 ثانية، فيجب أن تحصل قاعدة البيانات على جزء من ذلك فقط. اترك وقتًا للتشفير إلى JSON، والتبعيات الأخرى، والتعامل مع الأخطاء. نقطة بداية بسيطة هي إعطاء Postgres بين 30% و60% من الميزانية الكلية. مع مهلة معالج 2 ثانية، قد يكون ذلك 800ms إلى 1.2s.
عندما يُلغى السياق، يطلب الدرايفر من Postgres إيقاف الاستعلام. عادةً يعود الاتصال إلى التجمع ويمكن إعادة استخدامه. إذا حدث الإلغاء أثناء لحظة شبكية سيئة، قد يتخلص الدرايفر من الاتصال ويفتح واحدًا جديدًا لاحقًا. في كلتا الحالتين، تتجنب goroutine تنتظر إلى الأبد.
عند فحص الأخطاء، عالج المهلات بشكل مختلف عن أعطال قاعدة البيانات الحقيقية. إذا errors.Is(err, context.DeadlineExceeded), فهذا يعني أنك نفدت الوقت ويجب إرجاع مهلة. إذا errors.Is(err, context.Canceled), فالمستخدم اختفى ويجب التوقف بهدوء. الأخطاء الأخرى هي مشاكل استعلام عادية (SQL خاطئ، صف مفقود، صلاحيات).
إذا كان للمعالج موعد نهائي، يجب أن تحترم استدعاءات HTTP الصادرة ذلك أيضًا. وإلا، يتخلى العميل لكن يبقي خادمك في انتظار المنبع البطيء ويربط goroutines والمقابس والذاكرة.
ابنِ الطلبات الصادرة مع سياق الأب حتى تنتقل إشارة الإلغاء تلقائيًا:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
تعد مهلة كل استدعاء شبكة أمانًا. مهلة الطلب الأصلية لا تزال الرئيس. ساعة واحدة للطلب بأكمله، بالإضافة إلى حدود أصغر للخطوات الخطرة.
اضبط أيضًا مهلات على مستوى الترانسبورت. يلغي السياق الطلب، لكن مهلات الترانسبورت تحميك من المصافحات البطيئة والخوادم التي لا ترسل رؤوسًا.
تفصيل يعض الفرق: يجب إغلاق أجسام الاستجابة في كل مسار. إذا رجعت مبكرًا (فحص رمز الحالة، خطأ فك JSON، مهلة السياق)، أغلق الجسم دائمًا. تسريب الأجسام يمكن أن يستنفد الاتصالات في التجمع بهدوء ويتحول إلى ارتفاعات كمونية "عشوائية".
سيناريو ملموس: تستدعي API مزود دفع. العميل ينتهي بعد 2 ثانية، لكن المنبع يتعطل 30 ثانية. بدون إلغاء الطلب ومهلات الترانسبورت، تستمر في الانتظار 30 ثانية لكل طلب متروك.
الطلب الواحد عادةً يمس أكثر من شيء بطيء واحد: عمل المعالج، استعلام قاعدة بيانات، وواحد أو أكثر من واجهات HTTP الخارجية. إذا منحت كل خطوة مهلة سخية، ينمو الزمن الكلي حتى يشعر المستخدم وتبدأ الخادم بالتراكم.
الميزنة هي الإصلاح الأبسط. ضع موعدًا نهائيًا أبًا للطلب، ثم أعط كل تبعية جزءًا أصغر. يجب أن تكون مهل الأطفال أبكر من الأب حتى تفشل بسرعة وتبقى لديك وقت لارجاع خطأ نظيف.
قواعد عامة مجدية في الخدمات الحقيقية:
تجنّب تكديس مهلات تتعارض. إذا كان سياق المعالج لديه مهلة 2 ثانية وعميل HTTP لديك مهلة 10 ثوانٍ فأنت آمن لكن مربك. إذا كان العكس، قد يقطع العميل مبكراً لأسباب غير مرتبطة.
للعمل الخلفي (سجلات التدقيق، المقاييس، البريد الإلكتروني)، لا تعيد استخدام سياق الطلب. استخدم سياقًا منفصلاً بمهلة قصيرة حتى لا تقتل إلغاءات العميل التنظيف المهم.
معظم أخطاء المهلات ليست في المعالج. تحدث في طبقة أو اثنتين لأسفل، حيث تُفقد المهلة بهدوء. إذا ضبطت مهلات على الحافة لكن تجاهلتها في الوسط، فلا تزال تحصل على goroutines أو استعلامات DB أو استدعاءات HTTP تستمر بعد اختفاء العميل.
الأنماط الأكثر شيوعًا بسيطة:
context.Background() (أو TODO). هذا يفصل العمل عن إلغاء العميل وموعد المعالج.ctx.Done(). يُلغى الطلب لكن شفرتك تستمر في الانتظار.context.WithTimeout خاص به. ينتهي بك الأمر مع مؤقتات عديدة ومواعيد نهائية مربكة.ctx بالاستدعاءات المحمولة (استعلامات DB، HTTP الصادر، نشر الرسائل). مهلة المعالج لا تفعل شيئًا إذا تجاهلتها التبعية.فشل كلاسيكي: تضيف مهلة 2 ثانية في المعالج، ثم يستخدم المستودع context.Background() لاستعلام DB. تحت الحمل، يستمر الاستعلام البطيء حتى بعد أن تنازل العميل، وتكبر الكومة.
اصلح الأساسيات: مرّر ctx كمعامل أول عبر كومة الاستدعاءات. داخل الأعمال الطويلة، أضف فحوصًا سريعة مثل:
select {
case <-ctx.Done():
return ctx.Err()
default:
}
وحوّل context.DeadlineExceeded إلى استجابة مهلة (غالبًا 504) وcontext.Canceled إلى رد ملائم لإلغاء العميل (غالبًا 408 أو 499 حسب تقاليدك).
المهل تساعد فقط إذا رأيت حدوثها وتأكدت أن النظام يتعافى نظيفًا. عندما يصبح شيء ما بطيئًا، يجب أن يتوقف الطلب، تُفرَج الموارد، وتبقى واجهة API متجاوبة.
لكل طلب، سجّل نفس مجموعة الحقول الصغيرة حتى تقارن الطلبات العادية مقابل المهلات. أدرج موعد السياق (إن وجد) ولماذا انتهى العمل.
حقول مفيدة تشمل الموعد النهائي (أو "لا شيء"), الوقت الإجمالي المنقضي، سبب الإلغاء (مهلة مقابل إلغاء العميل)، تسمية عملية قصيرة ("db.query users", "http.call billing"), ومعرّف الطلب.
نمط بسيط يبدو هكذا:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
السجلات تساعدك على تصحيح طلب واحد. المقاييس تظهر الاتجاهات.
تتبّع بعض الإشارات التي ترتفع مبكرًا عندما تكون المهلات خاطئة: عدد المهلات حسب المسار والتبعية، الطلبات الجارية (ينبغي أن تستقر تحت الحمل)، وقت انتظار تجمع DB، ونسب الكمون (p95/p99) مفصولة بحسب النجاح مقابل المهلة.
اجعل البطء متوقعًا. أضف تأخيرًا مخصصًا للتصحيح في معالج واحد، أبطء استعلام DB بتأخير متعمد، أو غلف استدعاء خارجي بخادم اختبار ينام. ثم تحقق من شيئين: ترى خطأ المهلة، ويتوقف العمل قريبًا بعد الإلغاء.
اختبار تحميل صغير يساعد أيضًا. شغّل 20 إلى 50 طلبًا متزامنًا لمدة 30 إلى 60 ثانية مع تبعية بطيئة مفروضة. يجب أن يرتفع عدد goroutine والطلبات الجارية ثم يستقر. إذا استمروا في الصعود، فهناك شيء يتجاهل إلغاء السياق.
المهل تساعد فقط إذا طُبِقت في كل مكان يمكن أن ينتظر فيه الطلب. قبل النشر، أجرِ مراجعة على الكود وتأكد من اتباع القواعد نفسها في كل معالج.
context.DeadlineExceeded وcontext.Canceled.http.NewRequestWithContext (أو req = req.WithContext(ctx)) والعميل يحتوي على مهلات على الترانسبورت (dial, TLS, response header). تجنّب الاعتماد على http.DefaultClient في مسارات الإنتاج.تدريب "تبعية بطيئة" سريع قبل الإصدار يستحق العناء. أضف تأخيرًا اصطناعيًا 2 ثانية لاستعلام SQL واحد وتأكد من ثلاثة أمور: يرجع المعالج في الوقت المحدد، الاستعلام في DB يتوقف فعلاً (لا فقط المعالج متوقف)، وسجلاتك تقول بوضوح أنه كان مهلة DB.
تخيل نقطة نهاية مثل GET /v1/account/summary. نشاط مستخدم واحد يُشغّل ثلاث مسائل: استعلام PostgreSQL (الحساب والنشاط الأخير) واستدعاءان HTTP خارجيان (مثل فحص حالة الفوترة وطلب إثراء الملف الشخصي).
امنح الطلب كله ميزانية صارمة 2 ثانية. بدون ميزانية، يمكن لتبعية بطيئة واحدة أن تبقي goroutines واتصالات DB والذاكرة مربوطة إلى أن تبدأ واجهتك بالتأخر في كل مكان.
تقسيم بسيط قد يكون 800ms لقاعدة البيانات، 600ms للنداء الخارجي A، و600ms للنداء الخارجي B.
بمجرد معرفة الموعد النهائي الكلي، مرّره للأسفل. تحصل كل تبعية على مهلة أصغر خاصة بها، لكنها ترث إلغاء الأب.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
إذا تباطأ الاستدعاء الخارجي B وأخذ 2.5 ثانية، يجب أن يتوقف المعالج عن الانتظار عند 600ms، يلغي العمل الجاري، ويُرجع استجابة مهلة واضحة للعميل. يرى العميل فشلًا سريعًا بدلاً من مؤشر تحميل معلق.
يجب أن توضح سجلاتك ما استهلك الميزانية، على سبيل المثال: DB انتهى بسرعة، الخارجي A نجح، الخارجي B ضرب حدّه وأعاد context deadline exceeded.
بمجرد أن يعمل نقطة نهاية حقيقية مع المهلات والإلغاء بشكل جيد، حوّلها إلى نمط قابل للتكرار. طبّقها من الطرف إلى الطرف: مهلة المعالج، استدعاءات DB، وHTTP الصادر. ثم انسخ نفس البنية للنقطة التالية.
ستتحرك أسرع إذا مركزت الأجزاء المملة: مساعد مهلات على الحافة، أغلفة تضمن تمرير ctx إلى DB وHTTP، ونمط موحد لتعيين الأخطاء وتنسيق السجلات.
إذا أردت نموذجًا سريعًا لهذا النمط، يستطيع Koder.ai (koder.ai) توليد معالجات Go واستدعاءات خدمة من محادثة، ويمكنك تصدير الشفرة لتطبيق مساعدي المهلات والميزانيات بنفسك. الهدف هو الاتساق: تتوقف الاستدعاءات البطيئة مبكرًا، تظهر الأخطاء بنفس الشكل، وتصحيح الأعطال لا يعتمد على من كتب النقطة النهائية.
طلب بطيء يحتفظ بموارد محدودة أثناء انتظاره: goroutine، ذاكرة للحزم وكائنات الاستجابة، وغالباً اتصال بقاعدة البيانات أو اتصال عميل HTTP. عندما تنتظر عدة طلبات معاً، تتكوّن قوائم انتظار وترتفع الكمون لكل الحركة، وقد يتعطل الخدمة حتى لو كان كل طلب سينتهي في النهاية.
حدّد مهلة واضحة عند حدود الطلب (الوكيل/البوابة وفي خادم Go)، استخلص سياقًا ذا مهلة في المعالج، ومرّر ذلك الـ ctx إلى كل استدعاء قابل للحظر (قاعدة البيانات وHTTP الخارجي). عندما تنتهي المهلة، ارجع بسرعة برد موحّد وأوقف أي عمل جارٍ يدعم الإلغاء.
استخدم context.WithTimeout(parent, d) عندما تريد "التوقف بعد مدة معينة"، وهو الأكثر شيوعاً في المعالجات. استخدم context.WithDeadline(parent, t) إذا كان لديك وقت قطع محدد مسبقاً. استخدم context.WithCancel(parent) عندما يجب أن يتوقف العمل مبكراً بسبب حالة داخلية (مثل "لدينا جواب بالفعل" أو "انقطع العميل").
نادماً، استدعِ دالة الإلغاء دائمًا، عادةً defer cancel() مباشرة بعد إنشاء السياق المشتق. الإلغاء يحرر المؤقت ويعطي إشارة توقف واضحة لأي عمل فرعي، خصوصًا في المسارات التي تعود قبل أن تنتهي المهلة.
أنشئ سياق الطلب مرة واحدة في المعالج ومرّره كمعامل أول إلى الدوال التي قد تحجب. فحص سريع هو البحث عن context.Background() أو context.TODO() في مسارات الطلب؛ هذه غالبًا ما تقطع ربط المهلة وتكسر نشر الإلغاء.
استخدم طرق قاعدة البيانات الداعمة للسياق مثل QueryContext, QueryRowContext, وExecContext (أو ما يعادله في الدرايفر). عندما ينتهي السياق، يمكن للدرايفر أن يطلب من PostgreSQL إيقاف الاستعلام فلا تظل تحرق الوقت والاتصالات بعد انتهاء الطلب.
ألحق سياق الطلب الأب بالطلب الخارجي باستخدام http.NewRequestWithContext(ctx, ...)، وعبّئ أيضاً مهلات على مستوى العميل/الترانسبورت لحمايتك خلال الاتصال، TLS، وانتظار رؤوس الاستجابة. حتى عند الأخطاء أو ردود غير 200، أغلق جسم الاستجابة دائمًا حتى تعود الاتصالات إلى التجمع.
اختر ميزانية كلية واحدة للطلب أولًا، ثم وزّع الوقت المتبقي بين التبعيات بحيث تترك هامشًا صغيرًا لصياغة الاستجابة والعمليات العامة. إذا لم يتبق لدى سياق الأب إلا وقت قليل، فلا تبدأ عملًا يستهلك عادةً وقتًا أطول منه.
خيار شائع هو تحويل context.DeadlineExceeded إلى 504 Gateway Timeout مع رسالة قصيرة مثل "انتهت مهلة الطلب". أما context.Canceled فعادةً يعني أن العميل انقطع؛ أفضل إجراء غالبًا هو التوقف عن العمل والعودة دون كتابة جسم لإجتناب إهدار موارد إضافية.
الأخطاء الأكثر شيوعًا هي إسقاط سياق الطلب باستخدام context.Background(), بدء محاولات إعادة أو sleeps دون فحص ctx.Done(), ونسيان إلحاق ctx بالاستدعاءات التي تحجب. مشكلة أخرى دقيقة هي تراكم مهلات متعددة وغير منسقّة، مما يجعل الفشل صعب التنبؤ.