טיפול בשגיאות בקוד asyncio
הקוד הבא הוא גרסה פשוטה של קטע שכתב לי סוכן קידוד ואני שמח שעצרתי לקרוא לפני שהסכמתי לקבל אותו. בואו נראה את הקוד ואז ננתח מה הסוכן עשה יפה, איפה הוא טעה ומה אפשר ללמוד על ביטולים ושגיאות ב asyncio:
async def worker(..., queue, visited, ...):
while True:
try:
url, depth = await queue.get()
except asyncio.CancelledError:
break
try:
...
except Exception as e:
logger.error(f"Error processing {url}: {e}")
finally:
queue.task_done()
if stop_event.is_set():
break
הקוד מושך url מתור ואז עושה משהו עם ה url הזה, אבל מה שמעניין בו הוא כל פקודות הטיפול בשגיאות ובביטולים.
1. ביטול לפני קבלת אלמנט
נתבונן בבלוק הראשון:
while True:
try:
url, depth = await queue.get()
except asyncio.CancelledError:
break
מה פתאום יש שם טיפול נפרד ב CancelledError? ולמה רק הוא מקבל את ה break? התשובה שכשמבטלים פעולה אסינכרונית הביטול יגיע לאיזשהו await, כלומר כל await בפונקציה עלול לזרוק CancelledError. אם הביטול קורה לפני שמשכנו אלמנט מהתור עלינו לצאת מהלולאה בלי להודיע על task_done. בלי טיפול מיוחד זה השגיאה תגיע ל finally ו task_done ידווח גם במקרה של ביטול בזמן המתנה לאלמנט.
2. טיפול בשגיאות ללא ביטולים
הבלוק השני מטפל בשגיאות מסוג Exception. כאן צריך לזכור ש CancelledError יורש מ BaseException בפייתון ולא מ Exception ולכן לא יטופל ב except. אני מזכיר הקוד היה:
try:
...
except Exception as e:
logger.error(f"Error processing {url}: {e}")
finally:
queue.task_done()
if stop_event.is_set():
break
שגיאה רגילה תגיע כאן ל except ותציג את הודעת השגיאה. אחריה יופעל ה finally, המשימה תדווח בתור הושלמה ואם הופעל דגל היציאה אז נצא מהלולאה. ביטול יגיע ישר ל finally, שוב יוביל לדיווח על task_done אבל אז ימשיך לפעפע למעלה ויוציא אותנו מהלולאה.
מה הבעיה עם זה? קוד חיצוני שהריץ את הפונקציה וביטל יצטרך להתמודד עם תשובה לא עקבית - אם הביטול התרחש בזמן המתנה לפריט מהתור אז מפעילים break ופשוט מסיימים את הפונקציה רגיל (בתור הצלחה). אם הביטול התרחש בזמן ביצוע העבודה ה CancelledException יגרום לסיום הפונקציה וקוד חיצוני יוכל לראות שהיא התרסקה בגלל ביטול.
בעיה שניה בקוד היא הטיפול בשגיאות שאינן ביטולים - כאן הטיפול הוא בסך הכל לכתוב הודעה ללוג למרות שבעבודת רשת נכון יותר לנסות פריט כמה פעמים, לפחות עבור שגיאות זמניות. הקוד כאן לא כולל מנגנון לקיטלוג שגיאות וגם לא מנגנון לנסיונות חוזרים.
סך הכל הקוד היה עובד טוב יותר עם context manager שיטפל ב task_done במקום מבנה ה except הכפול. לג'מיני לא היתה בעיה לכתוב את זה עם כמו שרציתי כשביקשתי הטריק היחיד היה לראות את הבעיות בקוד המקורי ולהבין איך זה צריך להיות. זה הקוד שלקחתי בסוף:
from contextlib import asynccontextmanager
import asyncio
@asynccontextmanager
async def get_task(queue):
# If this is cancelled, Exception propagates and finally is NOT run (which is correct)
item = await queue.get()
try:
yield item
finally:
queue.task_done()
async def worker(queue):
# No need to catch CancelledError at all!
# When task.cancel() is called, the exception will break the loop
# automatically and cleanly cancel the task.
while True:
async with process_task(queue) as (url, depth):
... # Do work