ארכיטקטורת Retry ב-API: Exponential Backoff, Jitter ומניעת Retry Storm

מאת צוות מדיה דיל · 06.08.2026 · API Engineering · 8 דק׳

מנגנון Retry נאיבי לא רק לא פותר בעיית אמינות - הוא עלול ליצור Retry Storm שמחמיר תקלה זמנית. מדריך לבניית מנגנון Retry שבאמת מגן על המערכת.

שירות תשלומים חיצוני נופל לכמה דקות בשעת עומס. במקום שהמערכת "תספוג" את התקלה בעדינות, כל הבקשות הכושלות מנסות שוב מיידית, בלי שום השהיה - ותוך שניות, מה שהיה עומס רגיל הופך לגל של אלפי בקשות חוזרות שמכה את השירות ברגע שהוא מנסה להתאושש, ומשאיר אותו נופל עוד יותר זמן. זהו Retry Storm - אחת הדוגמאות הקלאסיות לכך שמנגנון Retry נאיבי לא רק לא פותר בעיית אמינות, הוא יכול ליצור אחת חדשה וחמורה יותר. ארכיטקטורת Retry נכונה היא לא "עטיפת try-catch עם לולאה", אלא מערכת שלמה שמאזנת בין התאוששות מהירה לבין הגנה על המשאבים שהיא פונה אליהם.

למה בכלל צריך Retry - ומתי הוא לא הפתרון

לא כל כשלון שווה לניסיון חוזר. יש להבחין בין שגיאות זמניות (Transient) - כמו timeout ברשת, 503 Service Unavailable, או ניתוק חיבור זמני - לבין שגיאות קבועות (Permanent) - כמו 400 Bad Request או 404 Not Found, שבהן חזרה על אותה בקשה בדיוק תיכשל שוב בוודאות. Retry על שגיאה קבועה הוא לא רק בזבוז משאבים, הוא מסווה בעיה אמיתית בקוד או בנתונים מאחורי "ניסיון נוסף" שרק דוחה את הגילוי שלה. הכלל הבסיסי: Retry הגיוני רק על קודי שגיאה שמסמנים במפורש מצב זמני (429, 502, 503, 504, וכשלי רשת ברמת ה-TCP), ולעולם לא על 4xx שמסמנים בעיה בבקשה עצמה (למעט 429 שהוא מקרה מיוחד של הגבלת קצב). חשוב גם להבדיל בין כשלון ברמת התחבורה (הבקשה כלל לא הגיעה ליעד, למשל timeout או DNS שנכשל) לבין כשלון שהתקבל בתשובה מפורשת מהשרת - במקרה הראשון אין ודאות אם הפעולה בוצעה בפועל, מה שמחזק עוד יותר את הצורך באידמפוטנטיות לפני כל ניסיון חוזר.

Exponential Backoff - הבסיס שכל מנגנון Retry צריך

ניסיון חוזר מיידי, כפי שראינו בתרחיש הפתיחה, הוא בדיוק מה שגורם ל-Retry Storm. הפתרון הבסיסי הוא Exponential Backoff - כל ניסיון נוסף ממתין פרק זמן שגדל אקספוננציאלית (למשל שנייה, שתי שניות, ארבע שניות, שמונה שניות), כך שהעומס על השירות שנכשל דועך במקום להצטבר:

async function retryWithBackoff(fn, maxRetries = 5, baseDelayMs = 1000) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries || !isRetryable(err)) throw err;
      const delay = baseDelayMs * (2 ** attempt);
      await sleep(delay);
    }
  }
}

גם עם Backoff אקספוננציאלי בלבד יש עדיין בעיה: אם אלף לקוחות שונים חוו את אותה תקלה בדיוק באותה שנייה, כולם ימתינו בדיוק אותו זמן וינסו שוב יחד - ה-Storm פשוט נדחה בזמן ולא נעלם. הפתרון לכך הוא הוספת רעש אקראי, Jitter.

Jitter - השבירה של סנכרון בלתי מכוון

Jitter מוסיף רכיב אקראי לזמן ההמתנה, כך שניסיונות חוזרים מפוזרים בזמן במקום מתרכזים יחד. יש כמה גרסאות נפוצות - Full Jitter (זמן המתנה אקראי לחלוטין בין אפס לערך המקסימלי המחושב) נותן את הפיזור הטוב ביותר אבל עלול ליצור זמני המתנה קצרים מדי בהתחלה; Equal Jitter (חצי מהזמן קבוע וחצי אקראי) מאזן בין פיזור לבין המתנה מינימלית סבירה. AWS ממליצה באופן ידוע על Full Jitter כברירת המחדל, וזה גם מה שאנחנו רואים הכי הרבה בפרודקשן:

function fullJitterDelay(baseDelayMs, attempt, capMs = 30000) {
  const exp = Math.min(capMs, baseDelayMs * (2 ** attempt));
  return Math.random() * exp;
}

שילוב של Exponential Backoff עם Jitter הוא כיום כמעט תקן דה-פקטו לכל מנגנון Retry רציני, ומרבית ה-SDK-ים הרשמיים של ספקי ענן גדולים כבר מממשים אותו כברירת מחדל.

מספר ניסיונות מקסימלי ותקרת זמן כוללת

Backoff אקספוננציאלי בלי תקרה (Cap) יכול לגרום לזמני המתנה בלתי סבירים - הניסיון העשירי עלול לחכות שעות. חשוב להגדיר תקרת זמן מקסימלית לכל ניסיון בודד (למשל 30 שניות), וגם מספר ניסיונות כולל מוגבל (בדרך כלל 3-5 מספיקים). מעבר לזה, חשוב לחשוב גם על תקציב זמן כולל לכל השרשרת - אם המשתמש ממתין לתשובה בזמן אמת (בקשת HTTP סינכרונית), אין טעם ל-5 ניסיונות עם Backoff שיחד עלולים לקחת דקה שלמה; עדיף להגביל את הזמן הכולל (למשל 10 שניות) ולהיכשל מהר יותר עם הודעה ברורה, ולהשאיר Retry אגרסיבי יותר לתהליכים א-סינכרוניים ברקע שבהם אין לחץ זמן מיידי.

Idempotency - התנאי המקדים שקל לפספס

Retry בטוח רק על פעולות אידמפוטנטיות - כלומר פעולות שביצוען פעמיים נותן את אותה תוצאה כמו ביצוען פעם אחת. GET, PUT ו-DELETE הן בדרך כלל אידמפוטנטיות מטבען. POST, לעומת זאת, בדרך כלל לא - Retry על יצירת הזמנה שנכשלה ברשת (אבל בפועל הצליחה בצד השרת לפני שהתשובה חזרה) עלול ליצור הזמנה כפולה. הפתרון הסטנדרטי הוא Idempotency Key - מזהה ייחודי שהלקוח שולח עם הבקשה, שהשרת שומר ומשתמש בו כדי לזהות ניסיון חוזר ולהחזיר את התוצאה המקורית במקום לבצע את הפעולה שוב:

POST /orders
Idempotency-Key: 8f14e45f-ceea-4d92-9e39-...

// בצד השרת: אם המפתח כבר נראה, מחזירים
// את התשובה השמורה במקום לעבד מחדש

בלי Idempotency Key, מנגנון Retry על פעולות כתיבה הוא לא רק לא בטוח - הוא מסוכן, כי הוא הופך תקלת רשת חד-פעמית לבאג עסקי אמיתי (חיוב כפול, הזמנה כפולה) שקשה הרבה יותר לתקן בדיעבד מאשר תקלת רשת מקורית.

Retry ברמות שונות של המערכת - מי אחראי על מה

שאלה חשובה שקל להתעלם ממנה היא באיזו שכבה ממש Retry - ב-SDK של הלקוח, ב-API Gateway, או בקוד השירות עצמו כשהוא קורא לשירות אחר. מימוש Retry בכמה שכבות בו-זמנית בלי תיאום ביניהן הוא מתכון ל-Retry Storm מוכפל: אם גם ה-SDK של הלקוח מנסה שוב וגם ה-Gateway מנסה שוב באופן עצמאי, כל כשלון יחיד הופך לכמה בקשות בפועל, לא אחת. הכלל המומלץ הוא לרכז את אחריות ה-Retry בשכבה אחת ברורה - בדרך כלל השכבה הכי קרובה למקור הבקשה (ה-SDK או שכבת ה-Gateway החיצונית), ולוודא ששכבות פנימיות יותר לא מוסיפות Retry עצמאי משלהן על אותה בקשה בלי לתאם עם השכבה שמעליהן.

כותרת Retry-After - תאום עם הצד שנכשל

לא תמיד צריך לנחש כמה זמן להמתין - שירותים רבים, במיוחד כאלה שאוכפים Rate Limiting, מחזירים כותרת Retry-After יחד עם תשובת 429 או 503, שמציינת במפורש כמה שניות (או מועד מדויק) לפני שכדאי לנסות שוב. מנגנון Retry שמכבד את הכותרת הזו כשהיא קיימת, ונופל חזרה ל-Exponential Backoff רק כשהיא חסרה, הוא הרבה יותר ידידותי לשירות המרוחק ולעיתים גם מקבל שירות טוב יותר בחזרה - חלק מהספקים מנטרים אילו לקוחות מכבדים את הכותרת הזו ומתייחסים אליהם בהתאם כשמדובר בהחלטות על חסימה ממושכת יותר. התעלמות שיטתית מ-Retry-After ונסיון לפי לוח זמנים עצמאי היא סימן היכר של אינטגרציה שנכתבה בלי לקרוא את תיעוד ה-API עד הסוף.

ניטור ומדידה של מנגנון Retry בפרודקשן

מנגנון Retry שעובד "בשקט" ומצליח ברוב המקרים מסתיר בעיה מתפתחת אם אין לו נראות. חשוב לחשוף Metrics ייעודיים - כמות ניסיונות Retry לפי endpoint, יחס הצלחה בניסיון הראשון מול ניסיונות חוזרים, וזמן כולל שבוזבז על המתנות Backoff. עלייה הדרגתית ביחס הבקשות שדורשות Retry היא לרוב אינדיקציה מוקדמת לבעיה בשירות תלוי, שמופיעה הרבה לפני שהיא הופכת לכשלון גלוי שמעיר את הצוות באמצע הלילה. כדאי גם להגדיר Alert על חריגה מסף סביר של יחס Retry - למשל אם יותר מ-5 אחוז מהבקשות ל-endpoint מסוים דורשות ניסיון חוזר, זה כנראה מצביע על בעיה שדורשת התייחסות, לא רק "המנגנון עושה את העבודה שלו".

שילוב עם Circuit Breaker - הגנה משלימה

Retry לבדו לא מגן מפני מצב שבו שירות שלם קרס לזמן ממושך - אם כל בקשה עדיין מנסה 5 פעמים לפני שהיא נכשלת, זה עדיין מכפיל את העומס פי חמישה על שירות שכבר לא מגיב. הפתרון המשלים הוא Circuit Breaker - מנגנון שמפסיק לנסות בכלל אחרי סף כשלונות מסוים, ונותן לשירות זמן להתאושש בלי הפצצה נוספת. שילוב נכון בין השניים הוא ש-Retry מטפל בכשלונות בודדים וחולפים, בעוד Circuit Breaker מטפל בכשלון מתמשך של שירות שלם - ולכן שני המנגנונים לרוב עובדים יחד, לא במקום זה את זה. הרחבה מלאה על מימוש Circuit Breaker אפשר למצוא במאמר הייעודי על Circuit Breaker ב-API.

טעויות נפוצות בפרודקשן

הטעות הראשונה, שהצגנו בפתיחה, היא Retry מיידי בלי Backoff כלל. השנייה היא Retry על פעולות לא אידמפוטנטיות בלי Idempotency Key, שיוצר כפילויות עסקיות. השלישית היא Retry על כל סוגי השגיאות כולל 4xx, מה שמסתיר באגים אמיתיים תחת "זה בטח עניין זמני". הרביעית, שמעטים חושבים עליה, היא חוסר לוגים על ניסיונות Retry - כשמנגנון Retry "בולע" כשלונות בשקט ומצליח בניסיון השלישי, הצוות לא רואה בכלל שיש בעיה מתפתחת, עד שהיא מחמירה מספיק כדי לגלוש גם מעבר למספר הניסיונות המקסימלי. חשוב לתעד (Log או Metric) כל ניסיון חוזר, גם אם בסופו של דבר הוא הצליח, כדי לתפוס מגמות שליליות מוקדם.

מתי Retry אגרסיבי מוצדק ומתי כדאי לצמצם

תהליכים ברקע שאין להם לחץ זמן משתמש (עיבוד תור, סנכרון נתונים) יכולים להרשות לעצמם Retry אגרסיבי יותר עם מספר ניסיונות גבוה ותקרת זמן ארוכה, כי אין מי שממתין בזמן אמת. בקשות סינכרוניות שמשתמש ממתין לתשובתן צריכות מדיניות שמרנית בהרבה - כשלון מהיר עם הודעה ברורה עדיף על המתנה ארוכה שמסתיימת גם היא בכשלון. חשוב גם להתאים את מדיניות ה-Retry לאופי השירות שנקרא - קריאה לשירות פנימי בתוך אותו רשת (עם latency נמוך וזמינות גבוהה) יכולה לסבול Retry מהיר ותכוף יותר מקריאה לשירות צד שלישי חיצוני, שם עדיף גישה שמרנית יותר שלא מכבידה על ספק שאין לכם שליטה עליו. שיקול נוסף הוא עלות הפעולה עצמה - Retry על קריאה שגובה תשלום בפועל מספק חיצוני (כמו שירות SMS או API בתשלום לפי בקשה) צריך להיות שמרני יותר, כי כל ניסיון כושל שעדיין נספר לחיוב הוא עלות כספית ישירה, לא רק עומס טכני.

סיכום

מנגנון Retry טוב הוא לא לולאת ניסיון-נוסף פשוטה, אלא מערכת שמתחשבת בסוג השגיאה, באידמפוטנטיות הפעולה, בפיזור העומס בזמן (Backoff עם Jitter), ובגבולות ברורים כדי לא להפוך תקלה קטנה לקריסה גדולה. ההשקעה בפרטים האלה - במיוחד Idempotency Keys ושילוב עם Circuit Breaker - היא מה שמבדיל בין מערכת שמתאוששת בחן מתקלות זמניות לבין מערכת שמכפילה כל תקלה קטנה לאסון גדול יותר. כלל אצבע פשוט לסיכום: לפני שמוסיפים Retry לכל קריאה חיצונית, שווה לשאול במפורש שלוש שאלות - האם הפעולה אידמפוטנטית, מה קורה אם כל הלקוחות נכשלים ומנסים שוב בו-זמנית, ומי בשרשרת כבר אחראי על ניסיון חוזר. תשובות ברורות לשלוש השאלות האלה חוסכות את רוב התקלות שמנגנוני Retry נאיביים גורמים בפועל.

תגיות: Retry · Exponential Backoff · Jitter · Idempotency Key · Circuit Breaker · API Reliability · Retry Storm

← חזרה לבלוג · צור קשר