خطای AggregateError در جاوااسکریپت؛ چرا Promise.any() بستهای از خطاها را برمیگرداند؟
AggregateError دقیقاً از کجا میآید، چرا ECMAScript آن را بهعنوان یک کلاس خطای مستقل معرفی کرد، و چه الگویی آن را از یک تهدید پنهان به یک ابزار مدیریت خطای حرفهای تبدیل میکند؟
نخستین باری که AggregateError را در یک پرونده واقعی دیدم، مربوط به یک سرویس تجمیع داده بود که همزمان از چند API بیرونی اطلاعات میگرفت؛ کاربر فقط یک پیام «خطا در دریافت داده» میدید، ولی در کنسول، یک شیء عجیب با فهرستی از چند خطای درونی ظاهر میشد. آن روز فهمیدم این خطا ابزار جدیدی است که ECMAScript عمداً طراحی کرده تا خطاهای موازی، جای گم شدن در لاگها، در یک ساختار قابلردیابی جمع شوند.
AggregateError در جاوااسکریپت دقیقاً چیست؟
AggregateError یکی از کلاسهای خطای داخلی استاندارد است که در نسخه نهم مشخصات ECMAScript — یعنی ECMAScript 2021 — به زبان اضافه شد. اسمش بهخوبی معنایش را میرساند: خطایی که خودش یک خطا نیست، بلکه ظرفی برای مجموعهای از خطاها است. این ظرف، یک ویژگی مخصوص دارد که آن را از همخانوادههایش مثل Error و TypeError جدا میکند: پراپرتی errors که یک آرایه از خطاهای عضو را نگه میدارد.
در نگاه اول ممکن است این طراحی اضافی به نظر برسد. چرا وقتی میتوانیم خطاها را در یک آرایه دستی جمع کنیم، به یک کلاس رسمی نیاز داریم؟ پاسخ در تجربه دیباگ من روشن است: وقتی خطاها در یک ساختار استاندارد جمع میشوند، ابزارها (از کنسول مرورگر تا سیستمهای تلمتری) میتوانند آنها را با ساختار درست نمایش دهند، در حالی که آرایههای دستی هر تیم یک شکل متفاوت دارند.
نکتهای که خیلی از توسعهدهندگان نمیدانند این است که AggregateError در دو بافت متفاوت ظاهر میشود. بافت اول، خودکار است؛ یعنی وقتی متدهای خاصی مثل Promise.any() از کار میافتند، خودشان این خطا را تولید میکنند. بافت دوم، دستی است؛ یعنی خود شما با new AggregateError(...) آن را میسازید تا در لایههای بالاتر، مجموعهای از خطاهای همخانواده را بهشکل ساختارمند منتقل کنید. هر دو بافت را در ادامه باز میکنم.
AggregateError یعنی خطاها را بهجای جمعکردن در آرایههای شخصی، در یک کلاس استاندارد بستهبندی کنیم تا ابزارها بتوانند آن را بخوانند.
یک نکته مهم درباره نام این کلاس: در منابع انگلیسی معمولاً از عبارت AggregateError استفاده میشود و مترادفهای فارسی مثل «خطای تجمیعی» یا «خطای گروهی» در مکالمات فنی رایجاند. در این متن هر دو معادل را بهکار میبرم، ولی در کد همیشه همان شناسه اصلی را خواهید دید. اگر با کلاسهای دیگر خطا آشنایی ندارید، مرور پیشزمینه در «آموزش جاوااسکریپت از صفر» نقطه شروع مناسبی است؛ چون این کلاس خطا بدون درک مفاهیم async، بیمعنا به نظر میرسد.
چرا ECMAScript در نسخه ۲۰۲۱ این کلاس خطا را اضافه کرد؟
پیش از ECMAScript 2021، اگر شما یک مجموعه از عملیات موازی را اجرا میکردید و همهشان شکست میخوردند، رفتار پیشفرض این بود که فقط اولین خطا بالا میآمد و بقیه در سایه میماندند. برای خیلی از سناریوها — مثل رقابت چند مرورگر برنده شدن، یا اجرای همزمان چند درگاه پرداخت — این رفتار کافی نبود. سازمانها میخواستند بدانند همه مسیرها شکست خوردهاند، و اگر شکست خوردهاند، چه دلایلی داشتهاند.
پروپوزال رسمی این قابلیت با نام Promise.any() مطرح شد. در همان پروپوزال، طراحان زبان به یک واقعیت مهم رسیدند: اگر Promise.any() بخواهد خروجی یک خطای گروهی بدهد، این خطا باید در استاندارد زبان وجود داشته باشد تا هر پیادهسازی آن را یکسان تفسیر کند. به همین دلیل، AggregateError بهعنوان یک کلاس خطای بومی پیش از معرفی Promise.any() به استاندارد اضافه شد.
این تصمیم طراحی، یک درس مهم دارد: زبان، وقتی ابزار جدیدی معرفی میکند، معمولاً ساختارهای پشتیبان آن را همزمان معرفی میکند. اگر امروز با AggregateError درگیر میشوید، بهتر است نگاه کنید که آن ابزار جانبی چه چیزی از شما میخواهد؛ چون در تجربه من، شکایت اصلی از این خطا همیشه بهشکل «چرا پیام واضح نیست؟» مطرح میشود، در حالی که خود پیام واضح است؛ فقط شما بهجای پراپرتی message باید پراپرتی errors را بخوانید.
از منظر آماری، بررسیهایی که روی مخازن کد عمومی انجام شده نشان میدهد پس از عرضه ES2021، استفاده از Promise.any() در پروژههای فرانتاند بهشکل تدریجی رشد کرده است، ولی همین رشد، باعث شده تعداد موارد واقعی AggregateError در کنسول کاربران هم بالا برود. همین است که این خطا امروز بیش از پیش در پروندههای دیباگ دیده میشود.
AggregateError یک خطای حاشیهای نیست؛ فرزند طبیعی تصمیمهای معماری زبان در دوره گذار به async پیشرفته است.
در همین دوره، مفاهیم دیگری هم به زبان اضافه شد که در آنها نیز رد پای مدیریت خطای همزمان دیده میشود. اگر میخواهید ببینید کدام قابلیتهای ES2021 به این خانواده تعلق دارند و بقیه تغییرات این نسخه چه بودند، مرور «آموزش es6 در جاوااسکریپت» نقطه شروع خوبی است؛ چون تفاوتهای ES6 و ES2021 در نگاه اول چندان بزرگ به نظر نمیرسند، ولی رفتار async را بهشکل محسوسی تغییر دادهاند.
ساختار داخلی: errors، message، cause و stack
برای اینکه بتوانید بهطور مؤثر با AggregateError کار کنید، باید بدانید دقیقاً چه چیزی درون آن است. شاید انتظار داشته باشید که این خطا تنها یک پیام داشته باشد، ولی در واقع چهار لایه اطلاعاتی جدا در آن قرار دارد که هر کدام برای هدفی طراحی شدهاند.
پراپرتی errors
اصلیترین پراپرتی، errors است که یک آرایه از خطاهاست. این آرایه، ترتیب خطاها را حفظ میکند و میتوانید با متدهای آرایهای روی آن کار کنید. اگر با متدهای آرایه آشنایی ندارید، مرور «آرایهها در جاوااسکریپت» میتواند به شما در تحلیل بهتر این آرایه کمک کند. نکته مهم این است که این آرایه همیشه پر است؛ اگر AggregateError از سمت Promise.any() آمده باشد، این آرایه به تعداد وعدههای ورودی عضو دارد.
پراپرتی message
پراپرتی message در AggregateError اختیاری است و اگر خودتان آن را نسازید، معمولاً یک پیام پیشفرض مثل "All promises were rejected" دریافت میکنید. نکته ظریف اینجاست که این پیام، بهتنهایی هیچ اطلاعات مفیدی از دلیل شکست نمیدهد. کاربرد اصلیاش، زمانی است که خودتان خطا را با یک توضیح معنادار میسازید.
پراپرتی cause
در ساختار جدید خطاهای جاوااسکریپت، cause به شما اجازه میدهد یک خطای درونی را بهعنوان ریشه به خطای بیرونی وصل کنید. در AggregateError نیز این پراپرتی پشتیبانی میشود. اگر خطای اصلی از یک عملیات بیرونی مثل fetch آمده باشد، استفاده از cause استاندارد است. پیشنهاد میکنم پیش از استفاده از cause در AggregateError، مطمئن شوید که الگوی ساخت خطا در پروژه شما یکسان است؛ وگرنه در لایههای بالاتر، بین کد و ابزار، شکاف اطلاعاتی ایجاد میشود.
پراپرتی stack
پراپرتی stack در AggregateError فقط به محل ساخت این خطا اشاره میکند، نه به stack هر یک از خطاهای درونی. به همین دلیل، stack این خطا معمولاً کوتاه و کمارزش است؛ آنچه ارزشمند است، stack هر عضو در آرایه errors است. اگر سیستم لاگینگ شما فقط error.stack را ثبت میکند، اطلاعات اصلی را از دست میدهید. این یکی از مهمترین دامهایی است که در پروژههای واقعی دیدهام.
try {
await Promise.any([
fetch("/api/a").then(r => r.json()),
fetch("/api/b").then(r => r.json()),
fetch("/api/c").then(r => r.json())
]);
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((inner, i) => {
console.error(`#${i}: ${inner.name} - ${inner.message}`, inner.stack);
});
}
}
این اسکریپت کوتاه، تفاوت اصلی AggregateError با خطاهای معمولی را نشان میدهد: شما باید در چند لایه پایین بروید تا به منبع واقعی خطا برسید. همین ویژگی است که این کلاس را از یک «ظرف خطا» به یک ابزار دیباگ تبدیل میکند، به شرطی که آن را درست بخوانید.
| پراپرتی | نوع | کاربرد اصلی |
|---|---|---|
errors | آرایه | لیست خطاهای درونی |
message | رشته | توضیح کلی از خودِ کلاس |
cause | هر مقدار | وصل کردن خطای ریشه به خطای بیرونی |
stack | رشته | فقط مسیر ساخت این خطا |
name | رشته | معمولاً "AggregateError" |
یک نکته فنی که در بازبینی کد زیاد به کارم آمده: نوع AggregateError را همیشه با instanceof چک کنید، نه با مقایسه name. چون در بعضی ترنسپایلرها و پالیفیلها، name ممکن است بازنویسی شود ولی instanceof همچنان قابل اعتماد است. همین دقت، باعث میشود کد شما در محیطهای مختلف قابل پیشبینی بماند.
Promise.any() و دلیل وجود AggregateError
اگر بخواهیم منبع اصلی AggregateError را در کد واقعی پیدا کنیم، تقریباً همیشه به Promise.any() میرسیم. این متد، برخلاف Promise.all() که منتظر موفقیت همه وعدهها میماند، فقط منتظر اولین موفقیت میماند. اگر هیچکدام از وعدهها موفق نشوند، یک AggregateError پرتاب میکند که حاوی خطای همه وعدههاست.
یک مثال ساده از این رفتار میتواند روشنگر باشد. فرض کنید سه درگاه پرداخت دارید و میخواهید از اولین درگاهی که پاسخ میدهد استفاده کنید. اگر هر سه شکست بخورند، Promise.any() خطای همه را در یک AggregateError بستهبندی میکند. اگر با مفهوم Promise آشنایی ندارید، پیشنهاد میکنم ابتدا «Promise در جاوااسکریپت» را مرور کنید؛ چون بدون درک چرخه عمر یک وعده، رفتار Promise.any() در نگاه اول عجیب به نظر میرسد.
مکانیزم داخلی این متد بسیار دقیق است. وقتی همه وعدهها رد شوند، موتور جاوااسکریپت یک AggregateError جدید میسازد و خطاهای رد شده را به همان ترتیب ورودی، در errors قرار میدهد. این ترتیب، در تحلیل ریشهای خطا اهمیت دارد، چون به شما اجازه میدهد بین مبدأهای مختلف تمایز بگذارید. اگر ترتیب را نادیده بگیرید و فقط به پیام کلی نگاه کنید، امکان تشخیص اینکه کدام سرویس بیرونی مشکل داشته، از دست میرود.
در کنار این متد، مفاهیم async و await نیز نقش مهمی در شکلگیری این خطا دارند. در واقع Promise.any() را میتوان هم با .then() و هم با await مصرف کرد، ولی رفتار خطا در این دو حالت یکسان است. برای درک عمیقتر این تفاوتها، مرور «async و await در جاوااسکریپت» به شما کمک میکند که بدانید چرا کد شما در برخی مسیرها یک AggregateError پرتاب میکند و در مسیرهای دیگر یک خطای ساده.
Promise.any، برادر کمترشناختهشده Promise.all است؛ ولی دقیقاً همان جایی که Promise.all از کمبود ابزار رنج میبرد، Promise.any با AggregateError پاسخ میدهد.
یک نکته عملی که در پروژهها به کارم آمده: همیشه قبل از استفاده از Promise.any()، مطمئن شوید که سناریوی شما واقعاً «اولین موفقیت» را میخواهد. در بعضی موارد، تیم فنی بهاشتباه از Promise.any() استفاده میکند در حالی که در واقع میخواسته «همه ورودیها اجرا شوند و در صورت شکست یکی، خطای کلی داده شود». این استفاده نادرست، باعث میشود AggregateError در جاهایی ظاهر شود که در واقع نیازی به آن نیست، و بهشکل مصنوعی، پیچیدگی خطاها را بالا ببرد.
تفاوت Promise.any با Promise.all و allSettled
کمتر خطایی در اکوسیستم async به اندازه AggregateError، رفتار درست را از استفاده نادرست جدا نمیکند. برای اینکه این تفاوت را دقیق درک کنید، سه متد اصلی خانواده Promise را در کنار هم ببینیم:
| متد | منتظر چه میماند؟ | در صورت شکست چه میدهد؟ |
|---|---|---|
Promise.all | موفقیت همه | اولین خطای رخداده |
Promise.allSettled | پایان همه | آرایهای از وضعیتها بدون پرتاب خطا |
Promise.any | اولین موفقیت | AggregateError با خطای همه در صورت شکست همه |
Promise.race | اولین پایان (موفق یا ناموفق) | نتیجه یا خطای اولین وعده |
تفاوت کلیدی بین Promise.allSettled و Promise.any در این است که اولی خطا پرتاب نمیکند و شما باید خودتان وضعیتها را بررسی کنید، در حالی که دومی خطا پرتاب میکند و آن خطا دقیقاً همان جایی است که AggregateError ظاهر میشود. انتخاب بین این دو، تصمیم معماری است، نه انتخاب سبک برنامهنویسی.
در تجربه من، یکی از رایجترین اشتباهات این است که تیمها Promise.all را برای سناریوی Promise.any استفاده میکنند و بعد، وقتی فقط یک وعده شکست میخورد و بقیه در سایه میمانند، به دنبال راهی برای «دیدن بقیه خطاها» میگردند. راهحل واقعی، نه patch کردن Promise.all، بلکه استفاده از متد درست از همان ابتدا است.
از زاویه دیگر، اگر خطاهای async برایتان بهطور کلی منبع دردسر است، مرور «مدیریت خطا در جاوااسکریپت» میتواند نقشه ذهنی شما را از این خانواده بزرگتر کند. آنجا تفاوت بین خطاهای همگام و ناهمگام، و ابزارهای مخصوص هرکدام، بهتفصیل آمده است.
شش سناریوی واقعی که AggregateError ظاهر میشود
در پروندههایی که به من رسیده، تعداد الگوهایی که به AggregateError منتهی میشوند بیشتر از آنچه انتظار میرود است. شش سناریوی زیر، تقریباً همه پروندههای عملی را پوشش میدهند.
سناریو اول: رقابت چند درگاه پرداخت
در برخی پروژهها، برای کاهش زمان تراکنش، همزمان به چند درگاه پرداخت درخواست فرستاده میشود و اولین پاسخی که برگردد، انتخاب میگردد. اگر همه درگاهها با خطا پاسخ بدهند — که در شرایط قطع اینترنت بینالملل میتواند رخ دهد — یک AggregateError با تعداد خطاهای برابر تعداد درگاهها پرتاب میشود. این سناریو، یکی از پرتکرارترین دلایل ظاهر شدن AggregateError در لاگهای سرور است.
سناریو دوم: جستجو در چند ارائهدهنده داده
در سرویسهای تجمیع داده، معمولاً از چند API بیرونی بهطور موازی پرسوجو میشود. اگر همه پاسخ منفی بدهند، Promise.any خطای همه را در یک AggregateError بستهبندی میکند. اگر روی این خطا مدیریت درست نداشته باشید، کاربر فقط پیام عمومی «خطا در دریافت داده» را میبیند و تیم فنی هم نمیفهمد کدام سرویس در دسترس نبوده است.
سناریو سوم: تلاش برای اتصال به چند CDN
در بعضی معماریهای حساس به تاخیر، مرورگر همزمان چند CDN را امتحان میکند و اولین پاسخی که برسد، منبع اصلی فایلها میشود. اگر همه CDNها در دسترس نباشند، AggregateError شکل میگیرد. در این سناریو، جنس خطاها معمولاً از نوع شبکه است و پیامها ممکن است در مرورگرهای مختلف متفاوت باشند.
سناریو چهارم: کوئریهای موازی در یک سرویس Node.js
در بکاندهایی که با Node.js نوشته شدهاند، یکی از الگوهای رایج این است که همزمان چند کوئری به سرویسهای داخلی ارسال شود و اولین پاسخ مورد استفاده قرار گیرد. اگر همه سرویسها در همان لحظه از کار افتاده باشند، AggregateError ظاهر میشود. در این بافت، بخش مهمی از کار دیباگ، شمردن اعضای errors و تطبیق آنها با نام سرویسهای داخلی است.
سناریو پنجم: استفاده دستی برای بستهبندی خطاها
در بعضی پروژهها، خود تیم فنی از new AggregateError استفاده میکند تا در لایههای بالاتر خطاهای یک batch را منتقل کند. مثال کلاسیک این الگو در فرآیندهای ETL دیده میشود که هر رکورد میتواند خطای مستقلی داشته باشد. در این حالت، AggregateError نه از سمت موتور جاوااسکریپت، بلکه از سمت کد شما تولید میشود و شکل آن کاملاً در اختیار شماست.
سناریو ششم: تجمیع خطاهای لایه اعتبارسنجی
وقتی یک فرم بزرگ با چند فیلد اعتبارسنجی میشود، بهجای پرتاب اولین خطا، میتوان تمام خطاها را در یک AggregateError جمع کرد. این الگو در فرانتاند مدرن و در فریمورکهایی مانند React و Svelte زیاد دیده میشود. اگر با قابلیتهای مدرن این زبان کار میکنید، مرور «شی گرایی در جاوااسکریپت» میتواند به شما کمک کند که این الگو را در قالب کلاسهای سفارشی خطا پیاده کنید.
در هر شش سناریو، یک نکته مشترک وجود دارد: AggregateError همیشه نتیجه تصمیم مشخصی است که در نقطهای از سیستم گرفته شده. اگر آن تصمیم را بفهمید، میتوانید خطا را در لایه درست مدیریت کنید. در غیر این صورت، مدام در پیچوخم پیامهای کوتاه موتور جاوااسکریپت میمانید.
چطور AggregateError فعلی را در پروژه ایزوله کنیم؟
فرض کنید همین امروز، یک AggregateError در محیط تولید ظاهر شده و میخواهید ریشهاش را پیدا کنید. روشی که در این نوع پروندهها به کار میگیرم، چهار گام دارد و هر گام، یک شرط را در ذهن من حذف میکند.
گام اول: نگاه به پیام و name
اولین کاری که میکنم، نگاه به error.name و error.message است. اگر name دقیقاً "AggregateError" باشد، میدانم با این کلاس طرفم و باید پراپرتی errors را باز کنم. اگر پیام «All promises were rejected» یا مشابه آن باشد، احتمالش بسیار است که منبع، یک Promise.any() باشد و نه یک خطای دستی. این تشخیص سریع، مسیر دیباگ را کوتاه میکند.
گام دوم: شمارش errors
بعد از باز کردن errors، اولین چیزی که به آن نگاه میکنم، تعداد آن است. اگر تعداد، دقیقاً با تعداد وعدههای یک عملیات موازی در کد خوانده شود، منبع تقریباً مشخص است. اگر تعداد بیشتر از حد انتظار باشد، احتمال دارد لایهای از کد شما، چندین AggregateError را روی هم بستهبندی کرده باشد. این الگوی تودرتو، در پروژههای بزرگ دیده میشود و مشکل اصلی آن این است که شما باید چند سطح خطا را باز کنید تا به ریشه برسید.
گام سوم: بررسی stack هر عضو
پس از شمارش، تکتک اعضای آرایه را با console.error یا ابزار لاگینگ خود بررسی میکنم. ابزاری که در این مرحله بهشکل خاص مفید است، کنسول مرورگر است؛ چون در آن میتوانید هر عضو را جداگانه باز کنید. روش پیدا کردن این خطاها در کنسول در «چگونه خطاهای جاوااسکریپت را در کنسول مرورگر پیدا کنیم» توضیح داده شده و برای این نوع خطا مستقیم قابل استفاده است.
گام چهارم: تطبیق با نقاط ورودی
در نهایت، stack هر عضو را با نقاط ورودی برنامه — یعنی مسیرهایی که به سرویسهای بیرونی متصل میشوند — تطبیق میدهم. اگر یکی از stackها به یک fetch اشاره کند و دیگری به یک درخواست دیتابیس، میفهمم که مشکل از کدام لایه نشئت گرفته. یکی از پرکاربردترین ابزارها در این مرحله، مرور ابزارهای اشکالزدایی است؛ فهرست کاملتر آن را در «ابزارهای اشکالزدایی جاوااسکریپت» جمع کردهام.
در این چهار گام، یک انضباط کوچک اما مهم وجود دارد: هرگز فقط به پیام message اکتفا نکنید. این پیام، برای اطلاعرسانی سریع طراحی شده، نه برای تحلیل عمیق. اگر تیم شما عادت دارد که در سیستم لاگ، فقط error.message را ثبت کند، بهجد پیشنهاد میکنم ساختار لاگ را تغییر دهید تا error.errors را هم ببیند. این تغییر، در بلندمدت از دهها ساعت دیباگ جلوگیری میکند.
الگوهای درست مدیریت AggregateError در کد مدرن
بعد از تشخیص، نوبت به رفع است. چهار الگوی عملی در پروژهها به کارم آمده که هر کدام، در بافت متفاوتی مناسب است.
الگوی اول: کاهنده (reducer) روی errors
رایجترین الگو این است که روی آرایه errors با reduce یا map کار کنید و یک پیام معنادار بسازید. مثال:
function summarizeAggregate(error) {
if (!(error instanceof AggregateError)) return error.message;
return error.errors
.map((e, i) => `#${i} ${e.name}: ${e.message}`)
.join(" | ");
}
این کد، خروجیای تولید میکند که هم در لاگ قابل خواندن است و هم به کاربر میتواند نمایش داده شود. برای پروژههایی که تجربه کاربری برایشان مهم است، نسخهای از این تابع که به زبان طبیعی فارسی برگرداند، ارزش بیشتری دارد.
الگوی دوم: جداسازی خطاها بر اساس type
در پروژههای بزرگ، مفید است که خطاها را بر اساس name یا کلاس دستهبندی کنید تا بتوانید سیاستهای متفاوتی اعمال کنید:
const buckets = error.errors.reduce((acc, e) => {
const key = e instanceof TypeError ? "type" :
e instanceof RangeError ? "range" :
e.name || "unknown";
(acc[key] ||= []).push(e);
return acc;
}, {});
این الگو به شما اجازه میدهد مثلاً روی خطاهای TypeError یک هشدار جدیتر بسازید و روی بقیه، فقط بازآزمایی کنید. این تمایز، در تجربه من، دقت تشخیص و سرعت رفع خطا را بهشکل محسوسی بالا میبرد.
الگوی سوم: بستهبندی مجدد برای لایه بالاتر
اگر تابع شما خودش AggregateError تولید میکند، بهتر است آن را با یک cause مجهز کنید تا به لایههای بالاتر یک اشاره یکتا به ریشه بدهد. این کار، مخصوصاً در پروژههای چندلایه، زمان دیباگ را کم میکند.
الگوی چهارم: ترکیب با retry منطقی
در بعضی سناریوها، پاسخ درست به AggregateError این نیست که فوراً شکست را اعلام کنید؛ بلکه باید بهشکل هوشمند یک retry انجام دهید. اگر همه خطاها از نوع شبکهای بودند، یک تأخیر کوتاه و تلاش دوباره میتواند کل مشکل را حل کند. برای درک دقیقتر این خانواده رفتارها، مرور «Unhandled Promise Rejection» میتواند تفاوت بین «خطای گرفتهشده» و «خطای رهاشده» را روشنتر کند.
مدیریت درست AggregateError یعنی تبدیل یک خطای ساختارمند به یک تصمیم عملی، نه فقط خواندن پیام آن.
در انتخاب بین این چهار الگو، هیچکدام را نباید بهعنوان نسخه «درست» در نظر گرفت. انتخاب، به بافت پروژه و کیفیت تجربهای که میخواهید بسازید بستگی دارد. در پروژههای کاربرمحور، الگوی اول و دوم کافی است؛ در پروژههای سیستمی و چندلایه، الگوی سوم و چهارم ارزش بیشتری دارند.
اشتباهات رایجی که این خطا را تشدید میکنند
در بررسی پروندههای خطای گروهی، شش اشتباه تکراری دیدهام که هر کدام، بهجای رفع مشکل، آن را پیچیدهتر میکند.
- نگاه کردن فقط به message. این پیام معمولاً حتی اشارهای به تعداد یا نوع خطاهای درونی ندارد. اگر فقط به آن تکیه کنید، منبع واقعی همیشه نامرئی میماند.
- فرض اینکه AggregateError همیشه از Promise.any میآید. این تصور غلط، شما را از بررسی سناریوهای دستی محروم میکند.
- نگهنداشتن stack اعضای errors. در سیستمهای لاگ که فقط
error.stackرا ذخیره میکنند، ارزشمندترین بخش اطلاعات از دست میرود. - استفاده از catch عمومی بدون تفکیک. اگر catch شما هر خطایی را بگیرد و یک پیام کاربرپسند برگرداند، تجربه کاربری ممکن است بهتر شود ولی دادههای دیباگ بدتر میشوند.
- استفاده نادرست از Promise.any. در بعضی پروژهها، این متد برای سناریویی استفاده میشود که در واقع Promise.allSettled مناسب بوده. این خطا را در کد تولید میکند، در حالی که نیازی به آن نبوده.
- بستهبندی چندین AggregateError در یکدیگر بدون تفکیک. وقتی خطاها تودرتو میشوند، تحلیل پیچیده میشود و اغلب تیم فنی فقط سطح بالایی را میبیند.
یک هفتمین اشتباه که در بازبینیها دیدهام، غفلت از استانداردسازی نوع خطا در لایههای میانی است. اگر یک لایه، خطاهای خودش را در آرایهای ساده برمیگرداند و لایه بعدی AggregateError میسازد، در طول زمان این دو شکل متفاوت میتوانند پیچیدگی غیرقابل مدیریتی ایجاد کنند.
پیامدهای امنیتی خطاهای گروهی
خطاهای گروهی، در نگاه اول مسئله امنیتی به نظر نمیرسند. ولی در عمل، دو مسیر مهم برای سوءاستفاده از آنها وجود دارد که در پروژههای واقعی دیدهام.
افشای اطلاعات از طریق پیامهای درونی
وقتی AggregateError را مستقیم به کلاینت برمیگردانید، در بعضی موارد پیامهای درونی شامل اطلاعات حساس مثل نام سرورهای داخلی، مسیر فایلها یا نام کاربری دیتابیس است. این اطلاعات، در دسترس مهاجم قرار میگیرد. راهحل استاندارد این است که در لبه API، یک لایه رفع اطلاعات (sanitization) بگذارید تا فقط پیامهای کاربرپسند بیرون بروند.
سوءاستفاده از الگوهای retry
اگر منطق retry شما بر اساس تعداد خطاهای درونی تصمیم میگیرد، یک مهاجم میتواند با ارسال ورودیهای خاص، تعداد این خطاها را بالا ببرد و برنامه را بهشکل غیرعادی به فعالیت بیشتر وادار کند. در طراحی هر منطق retry مبتنی بر AggregateError، باید حدود محکم (rate limits) و سیاست کاهش نمایی (exponential backoff) در نظر گرفته شود.
یک نکته کاربردی: هر زمان که در تیم شما بحث درباره نمایش پیام خطا به کاربر پیش میآید، یادآوری کنید که AggregateError یکی از پرخطرترین کلاسها برای افشای اطلاعات درونی است، چون ذاتاً شامل جزئیات است. توجه به این نکته در بازبینی کد، از بسیاری از نشتهای اطلاعاتی جلوگیری میکند.
ماتریس تست برای Promise.any و AggregateError
چیزی که در پروژههای بالغ بهشکل منظم میبینم و در پروژههای کمبلوغ غایب است، تستهای اختصاصی برای این کلاس خطا است. ماتریسی که در پروژهها استفاده میکنم، این شکلی است:
| سناریو | ورودی | خروجی مورد انتظار |
|---|---|---|
| همه موفق | سه Promise موفق | نتیجه اولین موفقیت |
| یک شکست، دو موفقیت | Promise.reject + دو موفق | نتیجه اولین موفقیت |
| همه شکست | سه Promise.reject | AggregateError با سه عضو |
| آرایه خالی | Promise.any([]) | AggregateError با آرایه خالی |
| همه با یک کلاس خطا | سه TypeError | AggregateError با سه TypeError |
| درهم از کلاسهای خطا | TypeError + RangeError + Error | AggregateError با اعضای متنوع |
| AggregateError تودرتو | Promise.any داخل Promise.any | AggregateError با AggregateError درونی |
هر ردیف از این ماتریس، یک سناریوی واقعی را پوشش میدهد. اگر این تستها را در پروژه خود بگذارید، نهفقط خطاهای زمان اجرا را زودتر میگیرید، بلکه وقتی تیم شما بزرگتر میشود، رفتار برنامه در برابر تغییرات، قابل پیشبینی میماند.
یک تذکر مهم: در تستهای async، مطمئن شوید که هر تست، بهشکل دقیق، خطا را در همان لایهای که انتظار دارید دریافت میکند. بعضی از فریمورکهای تست، خطاها را در لایهای بالاتر میگیرند و اگر شما به آن توجه نکنید، تستهای موفق میسازید که در محیط واقعی شکست میخورند.
پرسشهای پرتکرار درباره خطای AggregateError
در این بخش، پرسشهایی که بیشترین تکرار را در تیمهای فنی، تیکتهای پشتیبانی و جلسات بازبینی داشتهاند، پاسخ میدهم. هدف این است که بتوانید بهسرعت به پاسخ برسید، بدون اینکه لازم باشد کل مقاله را دوباره مرور کنید.
آیا AggregateError در مرورگرهای قدیمی پشتیبانی میشود؟
در مرورگرهای مدرن، پشتیبانی بومی از ES2021 وجود دارد. در مرورگرهای قدیمیتر، معمولاً با پالیفیل و ترنسپایلرها قابل استفاده است، ولی باید توجه داشته باشید که رفتار پالیفیل ممکن است در جزئیات با نسخه بومی تفاوت داشته باشد. بنابراین، تست در محیط هدف واقعی ضروری است.
آیا AggregateError فقط از Promise.any میآید؟
خیر. اگرچه شایعترین منبع آن در کدهای واقعی، Promise.any است، اما شما میتوانید خودتان AggregateError بسازید و در سناریوهای مختلفی مثل تجمیع خطاهای اعتبارسنجی، از آن استفاده کنید. این انعطاف، یکی از دلایل اصلی وجود این کلاس در استاندارد است.
چرا پیام AggregateError انقدر کوتاه است؟
چون طراحان زبان خواستهاند این کلاس، ظرفی برای خطاهای دیگر باشد، نه خودِ خطای اصلی. پیام کوتاه، به این معناست که مصرفکننده باید به پراپرتی errors برود تا اطلاعات واقعی را ببیند. بنابراین، پیام کوتاه، یک انتخاب عامدانه است، نه کمبود.
آیا در Node.js همان رفتار Promise.any را داریم؟
بله. AggregateError و Promise.any در Node.js نیز بهشکل استاندارد پشتیبانی میشوند. رفتار بین مرورگر و Node.js در این مورد بسیار یکسان است، ولی ممکن است پیامها و جزئیات stack تفاوتهای جزئی داشته باشند.
چطور میتوانم AggregateError را از خطاهای دیگر تشخیص دهم؟
بهترین راه، استفاده از instanceof AggregateError است. اگر پالیفیلها یا ترنسپایلرها در پروژه شما وجود دارند، مطمئن شوید که این چک، در همه محیطهای اجرایی قابل اعتماد است. تکیه بر error.name === "AggregateError" توصیه نمیشود، چون در بعضی پالیفیلها این مقدار میتواند تغییر کند.
آیا میتوانم خطاهای AggregateError را در یک ساختار داده دیگر بازنمایی کنم؟
بله. تبدیل آن به JSON، به یک ساختار دادهای با فیلدهای مشخص، یکی از کارهای رایج است. فقط توجه داشته باشید که در تبدیل به JSON، برخی اطلاعات مثل stack از دست میروند. بنابراین، ساختار ذخیرهسازی باید هم فیلدهای مختصر و هم فیلد stack را در خود جای دهد.
چرا AggregateError در تلمتری ابزارهای ابری متفاوت دیده میشود؟
ابزارهای تلمتری معمولاً بهطور خودکار پیام خطا را استخراج میکنند و به همین دلیل، در نگاه اول ممکن است فقط پیام «All promises were rejected» را ببینید. راهحل این است که در محل پرتاب خطا، خودتان با افزودن metadata به خطا، به تلمتری کمک کنید. این کار، مخصوصاً در محیطهای ابری، تفاوت بزرگی در کیفیت دادههای خطا ایجاد میکند.
نگاه معمارانه: خطاهای گروهی بهعنوان داده
در پروژههای بالغ، این خطا معمولاً بهعنوان بخشی از جریان داده دیده میشود، نه بهعنوان یک اتفاق تصادفی. سه تصمیم معمارانه که در تیمهای حرفهای دیدهام، این کلاس خطا را از یک خطر پنهان به یک ابزار قابل کنترل تبدیل میکند.
لایه اول: استانداردسازی نوع خطا
تیمهای حرفهای، برای هر خانواده خطا یک کلاس استاندارد تعریف میکنند. مثلاً یک ExternalServiceError که در آن، اطلاعات سرویس و وضعیت درونی نگه داشته میشود. وقتی همه خطاهای یک لایه، در یک کلاس استاندارد باشند، AggregateError هم شکل قابل پیشبینیتری پیدا میکند و لایههای بالاتر میتوانند با اطمینان روی آن کار کنند.
لایه دوم: کانالهای جدا برای اطلاعات حساس و کاربرپسند
در طراحی، باید دو کانال جداگانه داشته باشید: یکی برای تلمتری و لاگینگ داخلی، یکی برای پیام کاربر. کانال اول میتواند شامل تمام جزئیات خطاهای درونی باشد، در حالی که کانال دوم فقط پیامهای انسانی و بدون افشای اطلاعات را نشان میدهد. این جداسازی، هم امنیت را بالا میبرد و هم کیفیت تجربه کاربری را حفظ میکند.
لایه سوم: تصمیمگیری بر اساس نوع و تعداد خطا
در سناریوهای پیچیده، لایه بالایی باید بتواند بر اساس ترکیب خطاهای درونی تصمیم بگیرد؛ مثلاً اگر بیشتر خطاها از نوع شبکه باشند، سیاست retry را فعال کند و اگر بیشتر از نوع منطقی باشند، فوراً شکست را اعلام کند. این تصمیمگیری، بدون AggregateError تقریباً غیرممکن یا بسیار شکننده است.
در تمام این سه لایه، یک اصل مشترک وجود دارد: خطا نه بهعنوان شکست، بلکه بهعنوان دادهای با ساختار نگریسته میشود. همین دیدگاه است که تفاوت بین تیمهایی که از این کلاس خطا رنج میبرند و تیمهایی که آن را بهعنوان یک فرصت طراحی استفاده میکنند، ایجاد میکند.
خطاهای گروهی، وقتی بهعنوان داده با ساختار دیده شوند، از یک خطر پنهان به یک ابزار معماری تبدیل میشوند.
یک عادت مهندسی که این کلاس خطا را قابل پیشبینی میکند
AggregateError در نگاه اول کلاس خطای پیچیدهای به نظر میرسد، ولی در واقع یکی از سادهترین کلاسهای خطا در استاندارد است. تمام کاری که میکند این است که چند خطا را در یک آرایه بستهبندی میکند و آن آرایه را در پراپرتی errors قرار میدهد. پیچیدگی، از تصمیمهای ما میآید، نه از خود کلاس.
سه عادت عملی که پیشنهاد میکنم همین امروز شروع کنید: اول، در هر جایی که AggregateError ممکن است پرتاب شود، ساختار لاگ را طوری تنظیم کنید که هر عضو errors را ثبت کند. دوم، برای پروژههای چندلایه، یک کلاس استاندارد خطا تعریف کنید و AggregateError را در بالای آن قرار دهید. سوم، در تستهای خود، ماتریس سناریوهای async را بگنجانید تا رفتار این خطا در زمان تغییرات ناخواسته، قابل پیشبینی بماند.
اگر خطای مشابهی را در یک پروژه واقعی تجربه کردهاید — مخصوصاً جایی که با Promise.any سر و کار داشتید و ترتیب خطاها برایتان مهم بود — تجربهتان را در دیدگاه بنویسید. برای من جالب است بدانم کدام بخش از مدیریت این خطا بیشترین زمان شما را گرفت، و آیا الگویی پیدا کردید که با آنچه در این متن آمده، تفاوت داشت. تجربههای واقعی شما، این متن را برای خواننده بعدی دقیقتر میکند. 🧩