ספק התשלומים שהאתר מתחבר אליו מגמגם לשתי שניות. תקלת רשת רגעית, כזו שקורית כל הזמן. אבל במקום שהשירות יחזור לעצמו תוך רגע, הוא נשאר למטה רבע שעה, והגרפים מראים כמות בקשות גבוהה פי כמה מהרגיל. ברוב המקרים האשם אינו התקלה המקורית, אלא retry storm: גל של ניסיונות חוזרים שכל הלקוחות של השירות, כולל הקוד שלכם, שולחים בבת אחת על שרת שכבר מתקשה.
הבעיה רלוונטית במיוחד היום, כי כמעט כל אפליקציית Next.js וכל תהליך אוטומציה ב-n8n מדברים עם עשרות API חיצוניים: סליקה, הפקת חשבוניות, SMS, CRM. הרבה ספריות וכלים מגיעים עם ברירות מחדל שלא נבנו לתרחיש הזה. למשל, ב-ofetch ברירת המחדל של retryDelay היא 0, כלומר ניסיון חוזר מיידי, בלי backoff ובלי jitter (ofetch issue #581).
המדריך הזה מסביר איך נוצר retry storm, למה exponential backoff לבדו לא פותר אותו, ואיך בונים מדיניות retry שלמה: exponential backoff עם jitter, retry budget ו-circuit breaker. לאורך הדרך יש קוד TypeScript שעובד ב-Node.js, דוגמאות ליישום ב-Next.js, ודפוסים לעבודה נכונה עם n8n ו-webhooks.
איך תקלה של שתי שניות הופכת ל-retry storm
ניסיון חוזר הוא הימור: ההנחה היא שהכשל היה זמני ושבפעם הבאה הבקשה תצליח. כשההימור נכון, retry משפר אמינות. כשהוא שגוי, ובמיוחד כשכולם מהמרים באותו רגע, הוא הופך לבעיה עצמה.
לולאת המשוב
המנגנון פשוט ומרושע. שירות עמוס מחזיר שגיאות, הלקוחות מנסים שוב מיד, העומס הנוסף גורם לעוד שגיאות, ואלה גוררות עוד ניסיונות חוזרים. ה-retries, שנועדו לשפר אמינות, הופכים למנגנון שחוסם את ההתאוששות (Webalert).
הנקודה הקריטית: עומס ה-retry נוחת על שירות שכבר יש לו פחות קיבולת מהרגיל. אם השירות עובד בדרך כלל ב-70% קיבולת, ופתאום כל בקשה שנכשלה נשלחת שוב, הוא חוצה את ה-100% ולא יכול לרדת מתחתם כל עוד הלקוחות ממשיכים לנסות. זו בדיוק התופעה שמכונה thundering herd, או בשפה פחות מנומסת: DDoS שהלקוחות עושים לעצמם.
הגברה בין שכבות
מערכות אמיתיות בנויות משכבות: דפדפן, שרת Next.js, שירות backend, מסד נתונים או API חיצוני. אם כל שכבה מוסיפה retry משלה, ההגברה מוכפלת ולא מחוברת. שלוש שכבות שכל אחת מנסה שלוש פעמים הופכות בקשה אחת של משתמש ל-27 בקשות לשכבה התחתונה (Webalert).
בפועל זה נראה כך:
- הדפדפן משתמש בספריית data fetching עם retry מופעל כברירת מחדל.
- ה-route handler ב-Next.js עוטף את הקריאה ל-backend בלולאת retry.
- ה-SDK של ספק ה-API מנסה שוב בעצמו.
- ובמקביל, תהליך n8n שקורא לאותו ספק מוגדר עם Retry On Fail.
כל אחד מהם נראה סביר בבידוד. ביחד, הם מייצרים מכפיל עומס שאף אחד לא תכנן.
למה לולאת retry נאיבית מסוכנת
רוב ה-retry storms מתחילים מקוד שנראה תמים לגמרי. הנה הדוגמה הקלאסית:
// אל תעשו את זה בפרודקשן
async function naiveFetch(url: string, retries = 3): Promise<Response> {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(url);
if (res.ok) return res;
} catch {
// מתעלמים ומנסים שוב מיד
}
}
throw new Error('Failed after retries');
}יש כאן שלוש בעיות מרכזיות, וכל אחת מהן מספיקה כדי להחמיר תקלה.
ניסיון חוזר מיידי
בלי שום השהיה, הלולאה שולחת את הבקשה הבאה ברגע שהקודמת נכשלה. אם השרת נכשל בגלל עומס, הניסיון הבא מגיע בדיוק כשהעומס בשיאו. הקוד גם מנסה שוב על כל קוד סטטוס שאינו ok, כולל 400 או 401, שלא ישתנו לעולם בניסיון חוזר.
השהיה קבועה
התיקון האינטואיטיבי הוא להוסיף await sleep(1000). זה עדיף על כלום, אבל השהיה קבועה שומרת את כל הלקוחות מסונכרנים: כולם נכשלו באותו רגע, כולם ממתינים שנייה, כולם חוזרים יחד (ofetch issue #581). התוצאה היא גלים מחזוריים של עומס באותו גודל בדיוק.
ברירות מחדל של ספריות
לא תמיד כותבים את לולאת ה-retry בעצמכם. לפעמים היא מגיעה מובנית בספרייה. ב-ofetch, למשל, ברירת המחדל היא retry מיידי, ו-FetchContext לא חושף את מספר הניסיון, כך שקשה לממש backoff אמיתי מבחוץ. נכון לפתיחת ה-issue במאי 2026, אפשרות retryBackoff עם full jitter הייתה רק הצעה, עם PRs פתוחים (ofetch issue #581).
לעומת זאת, רוב ה-SDKs של AWS כבר תומכים ב-exponential backoff עם jitter במצבי retry מסוג standard או adaptive, כך שלא צריך לכתוב לוגיקה משלכם עבור קריאות AWS SDK (AWS Architecture Blog). המסקנה: לפני שכותבים retry, בודקים מה הספרייה כבר עושה, כדי לא ליצור שכבה כפולה.
Exponential backoff עם jitter: הפתרון לבעיית הסנכרון
הפתרון בנוי משני רכיבים שמשלימים זה את זה. backoff מקטין את נפח ה-retries, ו-jitter שובר את הסנכרון בין הלקוחות. הם לא חלופות אלא שני חצאים של אותו פתרון (Webalert).
Exponential backoff והחשיבות של cap
ב-exponential backoff ההשהיה מוכפלת בכל ניסיון: 1, 2, 4, 8, 16 שניות (YouTube). הנוסחה היא base * 2^attempt, כשהמעריך מתחיל מ-0, כך שה-base הוא ההמתנה הראשונה (Redisson).
בלי תקרה, ההכפלה יוצאת משליטה מהר מאוד. מ-base של שנייה אחת, בניסיון העשירי ההמתנה מגיעה ל-17 דקות ו-4 שניות, לעומת 30 שניות עם cap של 30 שניות (Redisson). לכן cap הוא חלק חובה בכל מדיניות retry.
למה backoff לבד לא מספיק
כאן נמצאת הטעות הנפוצה ביותר. exponential backoff מוריד את קצב הבקשות הממוצע, אבל לא את הסנכרון. לקוחות שמריצים את אותו קוד עם אותה קונפיגורציה נשארים בצעד אחיד, כך שגלי ה-retry נשארים באותו גודל ורק מגיעים ברווחים גדולים יותר (Redisson).
דמיינו 500 אינסטנסים שנכשלו באותו רגע. עם backoff דטרמיניסטי כולם חוזרים ב-t=1s, אחר כך ב-t=3s, אחר כך ב-t=7s. הגלים מתרחקים, אבל אף פעם לא קטנים (Redisson). ספר ה-SRE של Google מתאר זאת כך: אם ה-retries לא מפוזרים באקראי על פני חלון ה-retry, הפרעה קטנה כמו תקלת רשת יכולה לגרום לגלי retry להתזמן לאותו רגע ולהגביר את עצמם (Redisson).
שלוש אסטרטגיות jitter
jitter מוסיף אקראיות להשהיה. יש שלוש גרסאות נפוצות, שכולן מתוארות במאמר המקורי של Marc Brooker בבלוג הארכיטקטורה של AWS, שפורסם במרץ 2015 ועודכן במאי 2023 (AWS Architecture Blog):
- Full jitter:
sleep = random(0, min(cap, base * 2^attempt)). זו האסטרטגיה ש-AWS ממליצה עליה. היא לא דורשת state, ובסימולציות של AWS נתנה את העומס הנמוך ביותר על השרת ואת זמן ההשלמה הקצר ביותר (ofetch issue #581). - Equal jitter:
temp = min(cap, base * 2^attempt); sleep = temp/2 + random(0, temp/2). מבטיחה המתנה מינימלית של חצי מהערך (ofetch issue #581). - Decorrelated jitter:
sleep = min(cap, random(base, prev_sleep * 3)). דורשת לשמור את ערך ההמתנה הקודם (ofetch issue #581).
אסטרטגיה | יתרונות | חסרונות | מתי לבחור |
|---|---|---|---|
Full jitter | פיזור מקסימלי, עומס וזמן השלמה נמוכים בסימולציית AWS, ללא state | יכולה לייצר המתנה כמעט אפסית | ברירת המחדל המומלצת לרוב המקרים |
Equal jitter | מבטיחה המתנה מינימלית, עדיין שוברת סנכרון | פחות פיזור, ולכן עומס גבוה יותר | כשצריך רצפת המתנה, למשל מול rate limit קשיח |
Decorrelated jitter | פיזור טוב, התנהגות פחות צפויה | דורשת לשמור את ההמתנה הקודמת | לולאות ארוכות עם state זמין |
מקורות הטבלה: ofetch issue #581, Webalert.
Helper ב-TypeScript
הפונקציה הבאה מממשת את שלוש האסטרטגיות, וניתן להשתמש בה בכל סביבת Node.js, כולל Next.js:
// lib/backoff.ts
export type JitterStrategy = 'full' | 'equal' | 'decorrelated';
export interface BackoffOptions {
baseMs: number;
capMs: number;
strategy: JitterStrategy;
}
export function computeDelay(
attempt: number,
prevDelayMs: number,
{ baseMs, capMs, strategy }: BackoffOptions,
): number {
const exp = Math.min(capMs, baseMs * 2 ** attempt);
switch (strategy) {
case 'full':
return Math.random() * exp;
case 'equal':
return exp / 2 + Math.random() * (exp / 2);
case 'decorrelated': {
const upper = Math.max(baseMs, prevDelayMs * 3);
return Math.min(capMs, baseMs + Math.random() * (upper - baseMs));
}
}
}
export const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));כדי לקבל תחושה למספרים: עם base של 100ms, ארבעה ניסיונות כושלים עם backoff דטרמיניסטי מצטברים ל-100+200+400+800, כלומר 1,500ms של המתנה כוללת (Platformwale). עם full jitter, הממוצע הצפוי הוא בערך חצי מזה, והפיזור בין לקוחות שונה לגמרי.
מדיניות retry: מה בטוח לנסות שוב
אלגוריתם ההשהיה הוא רק חצי מהסיפור. החצי השני הוא ההחלטה אם לנסות שוב בכלל. כאן נקבע אם ה-retry משפר אמינות מערכות או מייצר נזק חדש, כמו חיוב כפול.
אילו שגיאות ראויות ל-retry
הכלל: מנסים שוב רק כשלים זמניים בפעולות idempotent. כלומר timeouts, connection resets, 429 ו-503. לעולם לא מנסים שוב שגיאות 4xx אחרות (Matthew Wong). 400, 401, 403 ו-404 הן טעויות של הצד הקורא, ושום כמות של ניסיונות לא תשנה אותן.
שגיאת 500 היא מקרה גבולי. היא יכולה להעיד על תלות עמוסה לרגע, אבל גם על באג שיחזור בכל ניסיון (imraan.in). גישה שמרנית היא לנסות שוב 502, 503 ו-504, ולטפל ב-500 לפי ההיכרות עם השירות הספציפי.
Idempotency: הבעיה של ה-timeout
timeout הוא כשל דו-משמעי. ייתכן שהבקשה מתה בדרך, וייתכן שרק התשובה אבדה. בתשלום, אין דרך לדעת אם השרת כבר ביצע את העבודה, ולכן idempotency היא שקובעת מה בטוח לנסות שוב (imraan.in).
בפועל:
- GET, HEAD, PUT, DELETE הן בהגדרה idempotent ברמת ה-HTTP, וברוב המקרים בטוח לנסות אותן שוב.
- POST אינה idempotent. מנסים שוב רק אם הפעולה idempotent מטבעה או אם היא נשלחת עם idempotency key (imraan.in).
- פעולות כתיבה תמיד נשלחות עם idempotency key, כדי שהשרת יזהה כפילויות (Matthew Wong).
זווית ישראלית חשובה: מפתחים שמתחברים לספקי סליקה, הפקת חשבוניות ו-SMS מקומיים צריכים לבדוק בתיעוד של כל ספק אם הוא תומך ב-idempotency key. אם לא, retry אוטומטי על חיוב או על הפקת חשבונית מס עלול לייצר כפילות שקשה מאוד לבטל. במקרה כזה עדיף לא לנסות שוב אוטומטית, אלא לבדוק קודם את סטטוס הפעולה (למשל לפי מזהה הזמנה פנימי) ורק אז להחליט.
לכבד את Retry-After
כשהשרת מחזיר 429 או 503 עם header של Retry-After, הוא אומר במפורש מתי לחזור. מדיניות retry טובה מכבדת את הערך הזה ולא מחליפה אותו בחישוב משלה (Matthew Wong). הערך יכול להגיע כמספר שניות או כתאריך HTTP:
// lib/retry-after.ts
export function parseRetryAfter(header: string | null): number | null {
if (!header) return null;
const seconds = Number(header);
if (!Number.isNaN(seconds)) return seconds * 1000;
const date = Date.parse(header);
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
return null;
}ארבעת הרכיבים של מדיניות retry
כל מדיניות retry מורכבת מארבעה רכיבים: השהיית בסיס, מקדם גדילה, תקרה (cap) ותקציב, כלומר מספר ניסיונות ו/או זמן כולל. מומלץ לתעד את מספר הניסיונות המקסימלי ואת זמן ההמתנה הגרוע ביותר, כדי שגם הצד שמקבל את הבקשות יידע למה לצפות (imraan.in).
הנה wrapper ל-fetch שמחבר את כל החלקים. העיקרון דומה לדוגמאות שמנסות שוב רק 5xx ושגיאות רשת וזורקות על 4xx (Haikel Fazzani), עם תוספות של deadline, idempotency ו-Retry-After:
// lib/fetch-with-retry.ts
import { computeDelay, sleep, type JitterStrategy } from './backoff';
import { parseRetryAfter } from './retry-after';
import type { RetryBudget } from './retry-budget';
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'];
export interface RetryOptions {
maxAttempts?: number;
deadlineMs?: number;
perAttemptTimeoutMs?: number;
baseMs?: number;
capMs?: number;
strategy?: JitterStrategy;
idempotent?: boolean;
budget?: RetryBudget;
}
export async function fetchWithRetry(
url: string,
init: RequestInit = {},
options: RetryOptions = {},
): Promise<Response> {
const {
maxAttempts = 3,
deadlineMs = 10_000,
perAttemptTimeoutMs = 3_000,
baseMs = 100,
capMs = 3_000,
strategy = 'full',
idempotent = IDEMPOTENT_METHODS.includes((init.method ?? 'GET').toUpperCase()),
budget,
} = options;
const startedAt = Date.now();
let prevDelay = baseMs;
let lastError: unknown = new Error('No attempts made');
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const isRetry = attempt > 0;
if (isRetry && budget && !budget.canRetry()) break;
budget?.recordAttempt(isRetry);
let retryAfterMs: number | null = null;
try {
const res = await fetch(url, {
...init,
signal: AbortSignal.timeout(perAttemptTimeoutMs),
});
if (res.ok || !idempotent || !RETRYABLE_STATUS.has(res.status)) {
return res; // הצלחה, או שגיאה שאסור לנסות שוב
}
retryAfterMs = parseRetryAfter(res.headers.get('retry-after'));
await res.body?.cancel();
lastError = new Error(`Retryable status ${res.status}`);
} catch (err) {
// timeout או שגיאת רשת: בטוח לנסות שוב רק בפעולה idempotent
if (!idempotent) throw err;
lastError = err;
}
if (attempt === maxAttempts - 1) break;
const backoff = computeDelay(attempt, prevDelay, { baseMs, capMs, strategy });
prevDelay = backoff;
const delay = retryAfterMs ?? backoff;
const remaining = deadlineMs - (Date.now() - startedAt);
if (delay > remaining) break; // לא מחכים מעבר ל-deadline
await sleep(delay);
}
throw lastError;
}שימו לב לשני מנגנוני זמן נפרדים: perAttemptTimeoutMs מגביל כל ניסיון בודד, ו-deadlineMs מגביל את כל הפעולה. בלי deadline, לולאת retry יכולה להחזיק בקשה של משתמש פתוחה הרבה אחרי שהוא כבר סגר את הדף.
Retry budget ו-circuit breaker: לחסום את ההגברה
גם retry מושלם ברמת הבקשה הבודדת יכול לייצר עומס גדול ברמת המערכת. אם 10,000 בקשות נכשלות, שלושה ניסיונות לכל אחת הם עדיין 30,000 בקשות. כאן נכנסים שני כלים שפועלים ברמת הלקוח כולו, ולא ברמת הבקשה.
Retry budget
retry budget הוא תקרה על ה-retries כחלק מהתעבורה הכוללת, בנוסף למגבלות ניסיונות וזמן. דוגמה מבוססת כללי SRE: עד שלושה ניסיונות לבקשה, ומגבלה ברמת הלקוח שמאפשרת retry רק כל עוד ה-retries מהווים פחות מ-10% מהבקשות (Matthew Wong).
ההיגיון: כשהתלות בריאה וכשלים נדירים, היחס נמוך ממילא ו-retries עוברים חופשי. כשהתלות קורסת והרבה בקשות נכשלות, התקציב נגמר מהר, והלקוח מפסיק להוסיף עומס. הנה מימוש פשוט עם חלון זמן מתגלגל:
// lib/retry-budget.ts
export class RetryBudget {
private requests = 0;
private retries = 0;
private windowStart = Date.now();
constructor(
private ratio = 0.1, // retries עד 10% מהבקשות
private windowMs = 10_000, // חלון מדידה של 10 שניות
private minRetries = 5, // רצפה לתעבורה נמוכה
) {}
private roll() {
if (Date.now() - this.windowStart > this.windowMs) {
this.requests = 0;
this.retries = 0;
this.windowStart = Date.now();
}
}
recordAttempt(isRetry: boolean) {
this.roll();
if (isRetry) this.retries++;
else this.requests++;
}
canRetry(): boolean {
this.roll();
return this.retries < this.minRetries || this.retries < this.requests * this.ratio;
}
}הפרמטר minRetries הוא תוספת מעשית: בשירות עם מעט תעבורה, 10% מעשר בקשות הם retry אחד בלבד, וזה עלול להיות קשוח מדי. את הערכים כדאי לכייל לפי נתוני התעבורה בפועל. ראוי לציין שהמספרים 10% ושלושה ניסיונות הם דוגמה ולא תקן מחייב; בסרטון הדרכה אחד, למשל, מופיעה המלצה ל-5 עד 7 ניסיונות ו-cap של כ-32 שניות (YouTube). ההמלצה הנכונה תלויה בהקשר: בקשה של משתמש שמחכה לתשובה סובלת הרבה פחות ניסיונות מ-job ברקע.
שכבת retry אחת בלבד
הכלל הפשוט והאפקטיבי ביותר נגד הגברה: retry בשכבה אחת בלבד (Matthew Wong). זכרו את החשבון של 3x3x3=27 (Webalert). בדרך כלל השכבה הנכונה היא זו שהכי קרובה לתלות הכושלת, כי היא יודעת הכי טוב אילו שגיאות זמניות. שאר השכבות צריכות להעביר את הכשל הלאה במהירות, באופן ברור.
Circuit breaker
retry מטפל בתקלות קצרות. אבל כשתלות ממש למטה, כל ניסיון הוא בזבוז, גם אם הוא מפוזר יפה. circuit breaker מפסיק לגמרי לקרוא לתלות שידוע שהיא שבורה (Platformwale). לפי הבלוג Platformwale, באירוע Kinesis של AWS ב-us-east-1 בנובמבר 2020, retries מסונכרנים הגבירו את העומס ושירותים עם circuit breakers שרדו. חשוב לציין שזה הניתוח של הבלוג, וכדאי לקרוא גם את סיכום האירוע הרשמי של AWS (Platformwale).
ל-circuit breaker שלושה מצבים:
- Closed: קריאות עוברות כרגיל, והכשלים נספרים.
- Open: אחרי סף כשלים, כל קריאה נדחית מיד (fail fast) בלי לפנות לתלות.
- Half-open: אחרי זמן המתנה, מתירים קריאת ניסיון. הצלחה סוגרת את המעגל, כשל פותח אותו מחדש.
// lib/circuit-breaker.ts
type State = 'closed' | 'open' | 'half-open';
export class CircuitOpenError extends Error {
constructor() {
super('Circuit open: failing fast');
}
}
export class CircuitBreaker {
private state: State = 'closed';
private failures = 0;
private openedAt = 0;
constructor(
private failureThreshold = 5,
private resetTimeoutMs = 30_000,
) {}
async exec<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.openedAt < this.resetTimeoutMs) {
throw new CircuitOpenError();
}
this.state = 'half-open';
}
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures++;
if (this.state === 'half-open' || this.failures >= this.failureThreshold) {
this.state = 'open';
this.openedAt = Date.now();
}
throw err;
}
}
}זה מימוש מינימלי לצורכי הסבר. במצב half-open הוא מאפשר כמה קריאות מקבילות, ואין בו ספירה לפי חלון זמן. בפרודקשן כדאי לשקול ספרייה ייעודית כמו opossum ל-Node.js. ההבדל בין שני הכלים: retry מטפל בתקלות קצרות, אבל לבדו מגביר עומס בתקלה מתמשכת; ה-breaker מוסיף state וכיול, ועלול לדחות קריאות גם כשהתלות כבר מתאוששת (imraan.in). לכן משתמשים בשניהם יחד.
יישום בפרודקשן: Next.js, n8n ו-webhooks
המקורות הכלליים על retry לא נכנסים לפרטים של Next.js או n8n, אבל העקרונות מתורגמים אליהם ישירות. הסעיף הזה מתמקד במקומות שבהם retry storm נוצר בפועל בפרויקטים כאלה.
Route handlers ב-Next.js
ב-route handler, שכבת ה-retry היא הקריאה ל-API החיצוני. כשהתלות למטה, השרת לא צריך לנסות עוד ועוד, אלא להחזיר ללקוח 503 ברור עם Retry-After:
// app/api/invoice-status/route.ts
import { NextResponse } from 'next/server';
import { fetchWithRetry } from '@/lib/fetch-with-retry';
import { CircuitBreaker, CircuitOpenError } from '@/lib/circuit-breaker';
import { RetryBudget } from '@/lib/retry-budget';
// state ברמת המודול: משותף לבקשות באותו אינסטנס
const breaker = new CircuitBreaker(5, 30_000);
const budget = new RetryBudget(0.1);
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'missing id' }, { status: 400 });
}
try {
const res = await breaker.exec(async () => {
const r = await fetchWithRetry(
`${process.env.BILLING_API_URL}/invoices/${id}`,
{ headers: { Authorization: `Bearer ${process.env.BILLING_API_KEY}` } },
{ budget, deadlineMs: 5_000, maxAttempts: 3 },
);
// ה-breaker צריך לראות 5xx ככשל
if (r.status >= 500) throw new Error(`Upstream ${r.status}`);
return r;
});
return NextResponse.json(await res.json(), { status: res.status });
} catch (err) {
const retryAfter = err instanceof CircuitOpenError ? '30' : '5';
return NextResponse.json(
{ error: 'billing service unavailable' },
{ status: 503, headers: { 'Retry-After': retryAfter } },
);
}
}כמה נקודות שחשוב להבין:
- State בסביבה serverless: ה-breaker וה-budget חיים ברמת המודול, כלומר בכל אינסטנס בנפרד. בסביבת serverless עם הרבה אינסטנסים, כל אחד מהם לומד בעצמו שהתלות למטה. זה עדיין משפר דרמטית את המצב, אבל אם צריך state משותף, אפשר להחזיק אותו ב-Redis.
- זמן ריצה מוגבל: לפונקציות serverless יש זמן ריצה מקסימלי שנקבע לפי הפלטפורמה והתוכנית. ה-
deadlineMsחייב להיות קצר ממנו בבירור, אחרת הפונקציה תיחתך באמצע לולאת ה-retry. - אין retry כפול בצד הלקוח: ספריות data fetching בדפדפן, כמו TanStack Query, מגיעות לרוב עם retry מופעל כברירת מחדל. אם השרת כבר מנסה שוב, כדאי לבטל או לצמצם את ה-retry בצד הלקוח, כדי לשמור על שכבת retry אחת.
מי שבונה אתרים שמייצרים לידים על Next.js ירגיש את זה ישירות: route handler שתקוע בלולאת retry מאט את הטופס ואת הדף, ופוגע בחוויה ובהמרות. הקשר בין ביצועים לתוצאות עסקיות מוסבר במאמר למה מהירות האתר שלכם קריטית להצלחה.
Server actions וטפסים עם idempotency key
ב-server actions יש סיכון נוסף: המשתמש עצמו הוא שכבת retry. לחיצה כפולה על כפתור תשלום, או רענון אחרי timeout, שולחים את אותה פעולה שוב. הפתרון הוא idempotency key שנוצר פעם אחת בצד הלקוח ונשלח עם כל ניסיון:
// app/checkout/checkout-form.tsx
'use client';
import { useState } from 'react';
import { createCharge } from './actions';
export function CheckoutForm({ amount }: { amount: number }) {
// נוצר פעם אחת לכל טופס, ולכן זהה בלחיצה כפולה
const [idempotencyKey] = useState(() => crypto.randomUUID());
return (
<form action={createCharge}>
<input type='hidden' name='idempotencyKey' value={idempotencyKey} />
<input type='hidden' name='amount' value={amount} />
<button type='submit'>לתשלום</button>
</form>
);
}// app/checkout/actions.ts
'use server';
import { fetchWithRetry } from '@/lib/fetch-with-retry';
export async function createCharge(formData: FormData) {
const idempotencyKey = String(formData.get('idempotencyKey'));
const amount = Number(formData.get('amount'));
const res = await fetchWithRetry(
`${process.env.PAYMENTS_API_URL}/charges`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount }),
},
// POST מסומן כ-idempotent רק בזכות המפתח
{ idempotent: true, maxAttempts: 3, deadlineMs: 8_000 },
);
if (!res.ok) throw new Error('Charge failed');
}הדפוס עובד רק אם ספק התשלומים באמת מכבד את ה-header. אם הספק לא תומך ב-idempotency key, הסירו את idempotent: true, ושמרו בצד שלכם מזהה פעולה שמונע שליחה כפולה.
n8n: הבעיה עם Retry On Fail
ב-n8n לכל node יש בהגדרות אפשרות Retry On Fail, עם מספר ניסיונות והמתנה בין ניסיונות. ההמתנה הזו קבועה, וזה בדיוק הדפוס של השהיה קבועה שתואר קודם: אם עשרות executions נכשלו מול אותו API באותו רגע, כולם חוזרים יחד אחרי אותו פרק זמן. למי שרק מתחיל עם הכלי, המדריך למתחילים לאוטומציה עם n8n מכסה את מבנה ה-nodes וההגדרות.
כמה דפוסים שמצמצמים את הסיכון:
- להשאיר retry בשכבה אחת: אם HTTP Request node קורא ל-API שה-SDK או ה-backend שלו כבר מנסים שוב, לא מפעילים עליו גם Retry On Fail.
- להגביל קצב: ב-HTTP Request node יש אפשרות Batching שמגבילה כמה פריטים נשלחים בכל פעם ובאיזה מרווח, וכך תהליך שמעבד מאות רשומות לא מציף את הספק.
- backoff עם jitter ידני: אפשר לבטל את Retry On Fail, לנתב כשל דרך error output ל-Code node שמחשב השהיה עם full jitter, ואז ל-Wait node שקורא את הערך דרך expression, עם IF node שעוצר אחרי מספר ניסיונות מוגדר.
Code node לחישוב ההשהיה יכול להיראות כך:
// n8n Code node: חישוב full jitter לניסיון הבא
const attempt = ($json.attempt ?? 0) + 1;
const baseSeconds = 2;
const capSeconds = 60;
const exp = Math.min(capSeconds, baseSeconds * 2 ** attempt);
const delaySeconds = Math.ceil(Math.random() * exp);
return [{ json: { ...$json, attempt, delaySeconds } }];ב-Wait node מגדירים את זמן ההמתנה כ-{{ $json.delaySeconds }}, וב-IF node בודקים שה-attempt קטן מהמקסימום. לפני שבונים את הלולאה הזו, כדאי לוודא שהנתונים המקוריים של הפריט נשמרים לאורך המסלול, ולא רק נתוני השגיאה. מי שמשווה בין פלטפורמות אוטומציה גם מבחינת היכולת לשלוט ב-retry ובטיפול בשגיאות ימצא רקע שימושי ב-השוואה בין Make, Zapier ו-n8n.
Webhooks: צד השולח וצד המקבל
ב-webhooks שני הצדדים מנסים שוב, ולכן הם מקור קלאסי ל-retry storms ולכפילויות.
כששולחים webhooks (למשל מ-n8n או מ-route handler לשירות של לקוח): משתמשים באותו fetchWithRetry, עם full jitter, cap, deadline ו-budget. שולחים מזהה אירוע ייחודי בכל webhook, כדי שהצד המקבל יוכל לזהות כפילויות.
כשמקבלים webhooks (למשל מספק סליקה): הספק כמעט בוודאות מנסה שוב כשהתשובה איטית או נכשלת. אם ה-endpoint מעבד את האירוע באופן סינכרוני ולוקח הרבה זמן, הספק יחשוב שנכשל וישלח שוב, ואז עוד פעם. כך נוצרים גם עומס וגם כפילויות. הדפוס הנכון:
// app/api/webhooks/payments/route.ts
import { markEventIfNew, enqueue, verifySignature } from '@/lib/webhooks';
export async function POST(request: Request) {
const rawBody = await request.text();
if (!verifySignature(request.headers, rawBody)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// לדוגמה: INSERT ... ON CONFLICT DO NOTHING, או SET NX ב-Redis
const isNew = await markEventIfNew(event.id);
if (!isNew) {
return new Response('duplicate', { status: 200 }); // כבר טופל
}
await enqueue(event); // העיבוד הכבד קורה מחוץ לבקשה
return new Response('ok', { status: 200 });
}שלושה עקרונות:
- לענות מהר: מאשרים קבלה ומעבירים לעיבוד אסינכרוני. ב-n8n, ל-Webhook node יש הגדרת Respond, ובחירה ב-Immediately מחזירה תשובה מיד במקום לחכות לסוף ה-workflow.
- dedupe לפי מזהה אירוע: כל אירוע מעובד פעם אחת, גם אם הגיע שלוש פעמים.
- קודי סטטוס נכונים: מחזירים 2xx על אירוע שהתקבל (גם אם הוא כפול), ו-4xx על בקשה פגומה, כדי שהספק לא ינסה שוב משהו שלעולם לא יצליח.
העקרונות האלה רלוונטיים במיוחד לתהליכים עסקיים שמחברים מערכות, כמו סנכרון לידים ל-CRM, נושא שמורחב במדריך איך לחבר את ה-CRM לאוטומציה.
צ'קליסט ו-observability
לפני שמעלים לפרודקשן קוד שקורא ל-API חיצוני, כדאי לעבור על הרשימה:
- האם יש retry ביותר משכבה אחת (דפדפן, שרת, SDK, n8n)?
- האם ההשהיה כוללת exponential backoff עם cap ו-full jitter?
- האם מנסים שוב רק timeouts, שגיאות רשת, 429 ו-5xx זמניים?
- האם פעולות כתיבה נשלחות עם idempotency key, או לא מנוסות שוב בכלל?
- האם יש deadline כולל שקצר ממגבלת הזמן של הפלטפורמה?
- האם יש retry budget ו-circuit breaker מול תלויות קריטיות?
- האם
Retry-Afterמכובד? - האם מספר הניסיונות המקסימלי וזמן ההמתנה הגרוע מתועדים (imraan.in)?
ומבחינת ניטור, כדאי למדוד:
- יחס retries לבקשות לכל תלות. קפיצה פתאומית היא סימן מוקדם לבעיה.
- מעברי מצב של ה-circuit breaker, עם התראה כשהוא נפתח.
- התפלגות מספר הניסיונות לבקשה, לא רק ממוצע.
- כפילויות שנחסמו ב-endpoint של webhooks, כמדד לכמה הספק מנסה שוב.
שאלות נפוצות
למה exponential backoff לא מספיק לבד?
בלי אקראיות, כל הלקוחות עוקבים אחרי אותו לוח זמנים דטרמיניסטי ונשארים מסונכרנים. הקצב הממוצע יורד, אבל כל גל נשאר באותו גודל. jitter הוא שובר את הסנכרון (Redisson).
אילו שגיאות כדאי לנסות שוב?
Timeouts, כשלי חיבור, 429 ו-503 (תוך כיבוד Retry-After), ולפעמים 500. שאר שגיאות ה-4xx הן טעויות של הצד הקורא ואין לנסות אותן שוב (imraan.in).
האם בטוח לנסות שוב בקשות POST?
רק אם הפעולה idempotent מטבעה או שהיא נשלחת עם idempotency key. אחרת, לא מנסים שוב אוטומטית, כי timeout לא מגלה אם העבודה כבר בוצעה (imraan.in). זה קריטי במיוחד בחיובים ובהפקת חשבוניות.
מה זה retry budget?
תקרה על כמות ה-retries כחלק מהתעבורה, בנוסף למגבלות ניסיונות וזמן. דוגמה אחת: עד שלושה ניסיונות לבקשה, ו-retries מותרים רק כל עוד הם פחות מ-10% מהבקשות (Matthew Wong).
איפה צריך לשבת ה-retry במערכת מרובת שכבות?
בשכבה אחת בלבד, כי שכבות מוערמות מכפילות זו את זו: שלוש שכבות עם שלושה ניסיונות כל אחת יוצרות 27 בקשות לשכבה התחתונה (Webalert).
האם צריך לכתוב backoff בעצמי?
לא תמיד. ספריות רבות כבר עושות זאת, ולכן כדאי לבדוק את ברירות המחדל שלהן. רוב ה-SDKs של AWS תומכים ב-backoff עם jitter (AWS Architecture Blog), בעוד ש-ofetch מגיעה עם retryDelay של 0 ובלי jitter (ofetch issue #581).
סיכום
retry storm הוא אחת התקלות המתסכלות בפרודקשן, כי הוא נוצר מקוד שנכתב כדי לשפר אמינות. ניסיונות חוזרים מיידיים, השהיות קבועות ו-retries בכמה שכבות הופכים תקלת רשת של שתי שניות לעומס מתמשך שמונע מהשירות להתאושש.
הפתרון בנוי בשכבות: exponential backoff עם cap מקטין את הנפח, full jitter שובר את הסנכרון, retry budget מגביל את ההגברה ברמת הלקוח, ו-circuit breaker מפסיק לקרוא לתלות שכבר למטה. מעליהם עומדים כללי מדיניות retry: מנסים שוב רק כשלים זמניים, רק פעולות idempotent או כאלה עם idempotency key, מכבדים Retry-After, ושומרים על שכבת retry אחת. ב-Next.js המשמעות היא deadlines שמתאימים למגבלות serverless ו-idempotency keys ב-server actions. ב-n8n וב-webhooks המשמעות היא להימנע מהמתנות קבועות, לענות מהר ולסנן כפילויות. מניעת קריסות בפרודקשן מתחילה בהבנה שכל retry הוא עומס נוסף, ושצריך לנהל אותו כמו כל משאב אחר.
מקורות
- Exponential Backoff And Jitter | AWS Architecture Blog
- Retry with Backoff and Jitter (No Retry Storms) | imraan.in
- Exponential Backoff & Jitter: Stop Retry Storms | Webalert
- Exponential Backoff and Jitter in Java | Redisson
- Retry with Exponential Backoff and Jitter in TypeScript | Matthew Wong
- Retry with Exponential Backoff and Jitter | Matthew Wong
- Resilience Patterns at Scale | Platformwale
- unjs/ofetch Issue #581
- Building Resilient Retry Logic in Node.js | dev.to
- Exponential Backoff with Jitter for the Fetch API | Haikel Fazzani
- Exponential backoff best practices | YouTube






